Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/lib/dataDir.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export function getDataDir() {
}

try {
fs.mkdirSync(configured, { recursive: true });
// 0o700: this directory holds the credential DB and the secret files.
fs.mkdirSync(configured, { recursive: true, mode: 0o700 });
return configured;
} catch (e) {
if (e?.code === "EACCES" || e?.code === "EPERM") {
Expand Down
8 changes: 6 additions & 2 deletions src/lib/db/backup.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// - Only the newest KEEP_BACKUPS are kept; older ones are pruned automatically.
import fs from "node:fs";
import path from "node:path";
import { BACKUPS_DIR, ensureDirs } from "./paths.js";
import { BACKUPS_DIR, ensureDirs, chmodQuiet, SECRET_DIR_MODE, SECRET_FILE_MODE } from "./paths.js";
import { timestampSlug, getAppVersion } from "./version.js";

const KEEP_BACKUPS = 3;
Expand All @@ -21,7 +21,7 @@ export function makeBackupDir(label) {
const ver = getAppVersion();
const slug = `${label}-${ver}-${timestampSlug()}`;
const dir = path.join(BACKUPS_DIR, slug);
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(dir, { recursive: true, mode: SECRET_DIR_MODE });
return dir;
}

Expand All @@ -30,6 +30,7 @@ export function backupFile(srcPath, destDir, destName = null) {
const name = destName || path.basename(srcPath);
const dest = path.join(destDir, name);
fs.copyFileSync(srcPath, dest);
chmodQuiet(dest, SECRET_FILE_MODE);
return dest;
}

Expand Down Expand Up @@ -59,6 +60,9 @@ export function backupDbLite(adapter, destDir, destName = "data.sqlite") {
} finally {
try { adapter.exec("DETACH DATABASE bak"); } catch {}
}
// SQLite creates the attached file itself, so it lands at 0644 under the
// default umask even though it contains a full copy of the credential tables.
chmodQuiet(dest, SECRET_FILE_MODE);
return dest;
}

Expand Down
6 changes: 5 additions & 1 deletion src/lib/db/driver.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ensureDirs, DATA_FILE } from "./paths.js";
import { ensureDirs, hardenPermissions, DATA_FILE } from "./paths.js";

// Use global to survive Next.js dev hot-reload (module state resets on reload)
if (!global._dbAdapter) global._dbAdapter = { instance: null, initPromise: null, logged: false };
Expand Down Expand Up @@ -63,6 +63,10 @@ async function initAdapter() {
if (!adapter) adapter = await trySqlJs();
if (!adapter) throw new Error("[DB] No SQLite driver available (bun/better/node/sql.js all failed)");

// After the adapter has created data.sqlite (plus -wal/-shm), tighten modes
// so the credential store is not world-readable. Also repairs existing installs.
hardenPermissions();

if (!state.logged) {
console.log(`[DB] Driver: ${adapter.driver} | file: ${DATA_FILE}`);
state.logged = true;
Expand Down
38 changes: 37 additions & 1 deletion src/lib/db/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,44 @@ export const LEGACY_FILES = {
disabled: path.join(DATA_DIR, "disabledModels.json"),
details: path.join(DATA_DIR, "request-details.json"),
};

// The DB holds provider OAuth access/refresh tokens and plaintext client API
// keys, so it is at least as sensitive as auth/cli-secret and jwt-secret (both
// already written with mode 0o600). Without an explicit mode it inherits the
// process umask — 022 on most systems — leaving it world-readable at 0644.
export const SECRET_DIR_MODE = 0o700;
export const SECRET_FILE_MODE = 0o600;

// chmod is a no-op for our purposes on Windows (ACL-based, only the read-only
// bit maps through), so restrict tightening to POSIX platforms.
const isPosix = process.platform !== "win32";

// Best-effort: a Docker bind mount may be owned by another uid, and failing to
// tighten permissions must never prevent the app from starting.
export function chmodQuiet(target, mode) {
if (!isPosix) return;
try {
fs.chmodSync(target, mode);
} catch {}
}

export function ensureDirs() {
for (const dir of [DATA_DIR, DB_DIR, BACKUPS_DIR]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: SECRET_DIR_MODE });
}
}

// Repair permissions on every startup. Creating new files with the right mode
// only protects fresh installs; existing installs already have 0755 dirs and a
// 0644 DB on disk, and SQLite writes in place so those modes persist forever.
export function hardenPermissions() {
if (!isPosix) return;
for (const dir of [DATA_DIR, DB_DIR, BACKUPS_DIR]) {
if (fs.existsSync(dir)) chmodQuiet(dir, SECRET_DIR_MODE);
}
// -wal and -shm are created by SQLite itself, so they inherit the umask too.
for (const suffix of ["", "-wal", "-shm"]) {
const file = `${DATA_FILE}${suffix}`;
if (fs.existsSync(file)) chmodQuiet(file, SECRET_FILE_MODE);
}
}
86 changes: 86 additions & 0 deletions tests/unit/db-file-permissions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// The DB stores provider OAuth tokens and plaintext client API keys, so the
// data dir must be 0700 and the DB file 0600 — not the 0755/0644 that the
// default umask (022) produces when no mode is given.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

let tempDir;
const originalDataDir = process.env.DATA_DIR;

// chmod is ACL-based on Windows and does not map to POSIX mode bits.
const describePosix = process.platform === "win32" ? describe.skip : describe;

const modeOf = (target) => fs.statSync(target).mode & 0o777;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-perms-"));
process.env.DATA_DIR = tempDir;
delete global._dbAdapter;
// DATA_DIR is resolved at module load, so the module graph must be rebuilt
// for each temp dir (same pattern as db-driver-chain.test.js).
vi.resetModules();
});

afterEach(() => {
try { global._dbAdapter?.instance?.close?.(); } catch {}
delete global._dbAdapter;
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
});

describePosix("DB file permissions", () => {
it("creates the data dir and DB file with owner-only permissions", async () => {
const { getAdapter } = await import("@/lib/db/driver.js");
await getAdapter();

const dbDir = path.join(tempDir, "db");
expect(modeOf(tempDir)).toBe(0o700);
expect(modeOf(dbDir)).toBe(0o700);
expect(modeOf(path.join(dbDir, "backups"))).toBe(0o700);
expect(modeOf(path.join(dbDir, "data.sqlite"))).toBe(0o600);
});

it("repairs world-readable permissions left by an existing install", async () => {
// Simulate a pre-fix install: dirs at 0755, DB at 0644.
const dbDir = path.join(tempDir, "db");
fs.mkdirSync(path.join(dbDir, "backups"), { recursive: true });
fs.writeFileSync(path.join(dbDir, "data.sqlite"), "");
fs.chmodSync(tempDir, 0o755);
fs.chmodSync(dbDir, 0o755);
fs.chmodSync(path.join(dbDir, "data.sqlite"), 0o644);

const { getAdapter } = await import("@/lib/db/driver.js");
await getAdapter();

expect(modeOf(tempDir)).toBe(0o700);
expect(modeOf(dbDir)).toBe(0o700);
expect(modeOf(path.join(dbDir, "data.sqlite"))).toBe(0o600);
});

it("keeps WAL sidecar files owner-only", async () => {
const { getAdapter } = await import("@/lib/db/driver.js");
await getAdapter();

const dbDir = path.join(tempDir, "db");
for (const suffix of ["-wal", "-shm"]) {
const sidecar = path.join(dbDir, `data.sqlite${suffix}`);
// Only better-sqlite3/node:sqlite in WAL mode create these.
if (fs.existsSync(sidecar)) expect(modeOf(sidecar)).toBe(0o600);
}
});

it("writes schema-migration backups owner-only", async () => {
const { getAdapter } = await import("@/lib/db/driver.js");
const adapter = await getAdapter();
const { makeBackupDir, backupDbLite } = await import("@/lib/db/backup.js");

const dir = makeBackupDir("perm-test");
const dest = backupDbLite(adapter, dir);

expect(modeOf(dir)).toBe(0o700);
expect(modeOf(dest)).toBe(0o600);
});
});