diff --git a/src/lib/dataDir.js b/src/lib/dataDir.js index 32819df43..538920b84 100644 --- a/src/lib/dataDir.js +++ b/src/lib/dataDir.js @@ -24,7 +24,8 @@ export function getDataDir() { const resolved = path.resolve(configured); try { - fs.mkdirSync(resolved, { recursive: true }); + /** Upstream PR #3381: newly configured credential directories start owner-only. */ + fs.mkdirSync(resolved, { recursive: true, mode: 0o700 }); return resolved; } catch (e) { if (e?.code === "EACCES" || e?.code === "EPERM") { diff --git a/src/lib/db/adapters/sqljsAdapter.js b/src/lib/db/adapters/sqljsAdapter.js index 5fa8dfda8..a157976ac 100644 --- a/src/lib/db/adapters/sqljsAdapter.js +++ b/src/lib/db/adapters/sqljsAdapter.js @@ -1,6 +1,7 @@ import fs from "node:fs"; import initSqlJs from "sql.js"; import { PRAGMA_SQL } from "../schema.js"; +import { SECRET_FILE_MODE } from "../paths.js"; let SQL = null; @@ -23,7 +24,8 @@ export async function createSqlJsAdapter(filePath) { function persist() { const data = db.export(); - fs.writeFileSync(filePath, Buffer.from(data)); + /** Upstream PR #3381: sql.js creates its credential database only on first persist. */ + fs.writeFileSync(filePath, Buffer.from(data), { mode: SECRET_FILE_MODE }); dirty = false; } diff --git a/src/lib/db/backup.js b/src/lib/db/backup.js index 6a5bafdab..8f0b01465 100644 --- a/src/lib/db/backup.js +++ b/src/lib/db/backup.js @@ -13,7 +13,13 @@ // - Only the newest KEEP_BACKUPS are kept; older ones are pruned automatically. import fs from "node:fs"; import path from "node:path"; -import { currentBackupsDir, ensureDirs } from "./paths.js"; +import { + chmodQuiet, + currentBackupsDir, + ensureDirs, + SECRET_DIR_MODE, + SECRET_FILE_MODE, +} from "./paths.js"; import { timestampSlug, getAppVersion } from "./version.js"; const KEEP_BACKUPS = 3; @@ -31,7 +37,8 @@ export function makeBackupDir(label) { for (let n = 0; ; n += 1) { const dir = n === 0 ? path.join(base, slug) : path.join(base, `${slug}-${n}`); try { - fs.mkdirSync(dir); // non-recursive: fails with EEXIST if taken, atomic under concurrency + /** Upstream PR #3381: each credential backup directory starts owner-only. */ + fs.mkdirSync(dir, { mode: SECRET_DIR_MODE }); // non-recursive: atomic EEXIST detection return dir; } catch (e) { if (e?.code === "EEXIST") continue; @@ -45,6 +52,8 @@ export function backupFile(srcPath, destDir, destName = null) { const name = destName || path.basename(srcPath); const dest = path.join(destDir, name); fs.copyFileSync(srcPath, dest); + /** Upstream PR #3381: copied credential backups must not inherit a broad mode. */ + chmodQuiet(dest, SECRET_FILE_MODE); return dest; } @@ -91,6 +100,8 @@ export function backupDbLite(adapter, destDir, destName = "data.sqlite") { return null; } try { adapter.exec("DETACH DATABASE bak"); } catch {} + /** Upstream PR #3381: SQLite creates ATTACH targets using the process umask. */ + chmodQuiet(dest, SECRET_FILE_MODE); return dest; } diff --git a/src/lib/db/driver.js b/src/lib/db/driver.js index c163c2a2a..dcf6b6c30 100644 --- a/src/lib/db/driver.js +++ b/src/lib/db/driver.js @@ -1,4 +1,4 @@ -import { ensureDirs, currentDataFile } from "./paths.js"; +import { ensureDirs, hardenPermissions, currentDataFile } 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, file: null }; @@ -69,6 +69,8 @@ async function initAdapter() { const dataFile = liveDataFile(); state.file = dataFile; + /** Upstream PR #3381: repair DB/WAL/SHM modes only after SQLite creates them. */ + hardenPermissions(); if (!state.logged) { console.log(`[DB] Driver: ${adapter.driver} | file: ${dataFile}`); state.logged = true; diff --git a/src/lib/db/paths.js b/src/lib/db/paths.js index 4ff680f36..70803b605 100644 --- a/src/lib/db/paths.js +++ b/src/lib/db/paths.js @@ -20,6 +20,45 @@ export function currentDataFile() { export function currentBackupsDir() { return path.join(currentDbDir(), "backups"); } + +/** Owner-only modes for credential-store artifacts (upstream PR #3381). */ +export const SECRET_DIR_MODE = 0o700; +export const SECRET_FILE_MODE = 0o600; +let permissionWarningEmitted = false; + +/** + * Applies a POSIX mode without making unsupported bind mounts or filesystems + * fatal during startup (upstream PR #3381). Windows ACLs are intentionally + * left untouched because chmod does not express this policy there. + */ +export function chmodQuiet(target, mode) { + if (process.platform === "win32") return; + try { + fs.chmodSync(target, mode); + } catch { + if (!permissionWarningEmitted) { + /** Upstream PR #3381: surface best-effort hardening failure once without exposing paths. */ + console.warn("[DB] Unable to harden credential-store permissions; continuing"); + permissionWarningEmitted = true; + } + } +} + +/** + * Repairs credential-store modes after SQLite creates its files (upstream + * PR #3381). Live path helpers preserve DATA_DIR changes across driver resets. + */ +export function hardenPermissions() { + if (process.platform === "win32") return; + for (const dir of [currentDataDir(), currentDbDir(), currentBackupsDir()]) { + if (fs.existsSync(dir)) chmodQuiet(dir, SECRET_DIR_MODE); + } + const dataFile = currentDataFile(); + for (const suffix of ["", "-wal", "-shm"]) { + const file = `${dataFile}${suffix}`; + if (fs.existsSync(file)) chmodQuiet(file, SECRET_FILE_MODE); + } +} export const LEGACY_FILES = { main: path.join(DATA_DIR, "db.json"), usage: path.join(DATA_DIR, "usage.json"), @@ -35,10 +74,12 @@ export function currentLegacyFiles() { details: path.join(dir, "request-details.json"), }; } +/** Creates or tightens live credential-store directories before DB open (upstream PR #3381). */ export function ensureDirs() { // Use live process.env.DATA_DIR so test mutations are honored between cases. const dir = currentDataDir(); for (const d of [dir, currentDbDir(), currentBackupsDir()]) { - if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); + if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true, mode: SECRET_DIR_MODE }); + else chmodQuiet(d, SECRET_DIR_MODE); } } diff --git a/tests/unit/db-file-permissions.test.js b/tests/unit/db-file-permissions.test.js new file mode 100644 index 000000000..57b9f9b68 --- /dev/null +++ b/tests/unit/db-file-permissions.test.js @@ -0,0 +1,181 @@ +/** + * Regression coverage for upstream PR #3381: credential-store directories and + * SQLite files must remain private, including repaired installs and backups. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const describePosix = process.platform === "win32" ? describe.skip : describe; +const originalDataDir = process.env.DATA_DIR; +let tempRoot; +let dataDir; + +function resetAdapterState() { + global._dbAdapter ||= { instance: null, initPromise: null, logged: false, file: null }; + Object.assign(global._dbAdapter, { instance: null, initPromise: null, logged: false, file: null }); +} + +function modeOf(target) { + return fs.statSync(target).mode & 0o777; +} + +beforeEach(async () => { + try { await global._dbAdapter?.instance?.close?.(); } catch {} + resetAdapterState(); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "durindoor-db-perms-")); + dataDir = path.join(tempRoot, "data"); + process.env.DATA_DIR = dataDir; + vi.resetModules(); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + vi.doUnmock("@/lib/db/adapters/bunSqliteAdapter.js"); + vi.doUnmock("@/lib/db/adapters/betterSqliteAdapter.js"); + vi.doUnmock("@/lib/db/adapters/nodeSqliteAdapter.js"); + vi.doUnmock("@/lib/db/migrate.js"); + try { await global._dbAdapter?.instance?.close?.(); } catch {} + resetAdapterState(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +describePosix("DB file permissions", () => { + it("passes owner-only modes to both directory creators before hardening", async () => { + const mkdir = vi.spyOn(fs, "mkdirSync"); + await import("@/lib/dataDir.js"); + const { ensureDirs } = await import("@/lib/db/paths.js"); + ensureDirs(); + + expect(mkdir).toHaveBeenCalledWith(dataDir, { recursive: true, mode: 0o700 }); + expect(mkdir).toHaveBeenCalledWith(path.join(dataDir, "db"), { recursive: true, mode: 0o700 }); + expect(mkdir).toHaveBeenCalledWith(path.join(dataDir, "db", "backups"), { recursive: true, mode: 0o700 }); + }); + + it("tightens existing directories before opening a database driver", async () => { + const dbDir = path.join(dataDir, "db"); + const backupsDir = path.join(dbDir, "backups"); + const dirs = [dataDir, dbDir, backupsDir]; + fs.mkdirSync(backupsDir, { recursive: true }); + for (const dir of dirs) fs.chmodSync(dir, 0o755); + let modesAtOpen; + vi.doMock("@/lib/db/adapters/betterSqliteAdapter.js", () => ({ + createBetterSqliteAdapter: () => { + modesAtOpen = dirs.map(modeOf); + return { driver: "permission-order-test" }; + }, + })); + vi.doMock("@/lib/db/migrate.js", () => ({ runMigrationOnce: vi.fn() })); + + const { getAdapter } = await import("@/lib/db/driver.js"); + await expect(getAdapter()).resolves.toHaveProperty("driver", "permission-order-test"); + expect(modesAtOpen).toEqual([0o700, 0o700, 0o700]); + }); + + it("creates credential-store directories and SQLite files owner-only", async () => { + const { getAdapter } = await import("@/lib/db/driver.js"); + await getAdapter(); + + const dbDir = path.join(dataDir, "db"); + for (const dir of [dataDir, dbDir, path.join(dbDir, "backups")]) { + expect(modeOf(dir)).toBe(0o700); + } + for (const suffix of ["", "-wal", "-shm"]) { + const file = path.join(dbDir, `data.sqlite${suffix}`); + expect(fs.existsSync(file)).toBe(true); + expect(modeOf(file)).toBe(0o600); + } + }); + it("creates the sql.js fallback database owner-only", async () => { + vi.doMock("@/lib/db/adapters/bunSqliteAdapter.js", () => { + throw new Error("force sql.js fallback"); + }); + vi.doMock("@/lib/db/adapters/betterSqliteAdapter.js", () => { + throw new Error("force sql.js fallback"); + }); + vi.doMock("@/lib/db/adapters/nodeSqliteAdapter.js", () => { + throw new Error("force sql.js fallback"); + }); + const previousUmask = process.umask(0o022); + try { + const { getAdapter } = await import("@/lib/db/driver.js"); + const adapter = await getAdapter(); + expect(adapter.driver).toBe("sql.js"); + await adapter.close(); + expect(modeOf(path.join(dataDir, "db", "data.sqlite"))).toBe(0o600); + resetAdapterState(); + } finally { + process.umask(previousUmask); + } + }); + + it("repairs a world-readable existing credential store", async () => { + const dbDir = path.join(dataDir, "db"); + const backupsDir = path.join(dbDir, "backups"); + fs.mkdirSync(backupsDir, { recursive: true }); + fs.writeFileSync(path.join(dbDir, "data.sqlite"), ""); + for (const dir of [dataDir, dbDir, backupsDir]) fs.chmodSync(dir, 0o755); + fs.chmodSync(path.join(dbDir, "data.sqlite"), 0o644); + + const { getAdapter } = await import("@/lib/db/driver.js"); + await getAdapter(); + + for (const dir of [dataDir, dbDir, backupsDir]) expect(modeOf(dir)).toBe(0o700); + expect(modeOf(path.join(dbDir, "data.sqlite"))).toBe(0o600); + }); + + it("chmods copy and ATTACH backup files owner-only", async () => { + const { backupDbLite, backupFile, makeBackupDir } = await import("@/lib/db/backup.js"); + const source = path.join(dataDir, "source.sqlite"); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(source, "credential data", { mode: 0o644 }); + + const copyDir = makeBackupDir("copy-permissions"); + const copied = backupFile(source, copyDir); + expect(modeOf(copyDir)).toBe(0o700); + expect(modeOf(copied)).toBe(0o600); + + const attachDir = makeBackupDir("attach-permissions"); + const attachAdapter = { + exec(sql) { + const match = /^ATTACH DATABASE '(.+)' AS bak$/.exec(sql); + if (match) fs.writeFileSync(match[1].replace(/''/g, "'"), "credential data", { mode: 0o644 }); + }, + all: () => [], + transaction(fn) { fn(); }, + }; + const attached = backupDbLite(attachAdapter, attachDir); + expect(modeOf(attachDir)).toBe(0o700); + expect(modeOf(attached)).toBe(0o600); + }); + + it("keeps startup working and warns once when chmod is unsupported", async () => { + fs.mkdirSync(path.join(dataDir, "db", "backups"), { recursive: true }); + const chmod = vi.spyOn(fs, "chmodSync").mockImplementation(() => { + throw Object.assign(new Error("operation not supported"), { code: "ENOTSUP" }); + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { getAdapter } = await import("@/lib/db/driver.js"); + await expect(getAdapter()).resolves.toHaveProperty("driver"); + expect(chmod).toHaveBeenCalled(); + expect(warn.mock.calls.filter(([message]) => message === "[DB] Unable to harden credential-store permissions; continuing")).toHaveLength(1); + }); + + it("skips chmod on Windows", async () => { + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + const chmod = vi.spyOn(fs, "chmodSync"); + try { + Object.defineProperty(process, "platform", { ...platform, value: "win32" }); + const { chmodQuiet, hardenPermissions } = await import("@/lib/db/paths.js"); + chmodQuiet(dataDir, 0o700); + hardenPermissions(); + expect(chmod).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, "platform", platform); + } + }); +});