diff --git a/src/lib/dataDir.js b/src/lib/dataDir.js index dff51e90a2..05118fb229 100644 --- a/src/lib/dataDir.js +++ b/src/lib/dataDir.js @@ -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") { diff --git a/src/lib/db/backup.js b/src/lib/db/backup.js index f3a7ed15c2..3c44eadd32 100644 --- a/src/lib/db/backup.js +++ b/src/lib/db/backup.js @@ -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; @@ -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; } @@ -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; } @@ -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; } diff --git a/src/lib/db/driver.js b/src/lib/db/driver.js index 050514d9e8..de9cd4ea25 100644 --- a/src/lib/db/driver.js +++ b/src/lib/db/driver.js @@ -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 }; @@ -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; diff --git a/src/lib/db/paths.js b/src/lib/db/paths.js index 5757f7b4c7..6d8d2e5200 100644 --- a/src/lib/db/paths.js +++ b/src/lib/db/paths.js @@ -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); } } diff --git a/tests/unit/db-file-permissions.test.js b/tests/unit/db-file-permissions.test.js new file mode 100644 index 0000000000..8d873433fd --- /dev/null +++ b/tests/unit/db-file-permissions.test.js @@ -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); + }); +});