Skip to content
Merged
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 @@ -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") {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/db/adapters/sqljsAdapter.js
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;
}

Expand Down
15 changes: 13 additions & 2 deletions src/lib/db/backup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 3 additions & 1 deletion src/lib/db/driver.js
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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;
Expand Down
43 changes: 42 additions & 1 deletion src/lib/db/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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);
}
}
181 changes: 181 additions & 0 deletions tests/unit/db-file-permissions.test.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading