From 9a420988bc1b63a9cab270e331ee6e762726cb1b Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 17:29:52 -0400 Subject: [PATCH] fix(analytics): support sqlite3 path resolution on Windows and NixOS (#1354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CCS_SQLITE_BIN env-var override; validated with fs.realpathSync so symlinks are fully resolved before prefix check - Reject any override whose realpath does not start under a trusted system prefix (prevents PATH-hijack reintroduction from #1347) - Add TRUSTED_PREFIX_UNIX covering /nix/store/, /opt/local/ (MacPorts), /snap/, /run/current-system/ in addition to existing /usr/* and /opt/homebrew/ entries - Add TRUSTED_PREFIX_WINDOWS covering Program Files, System32, and the Chocolatey managed bin dir (no canonical winget/Scoop path exists) - Keep TRUSTED_SQLITE_PATHS_WINDOWS empty — Windows users set CCS_SQLITE_BIN - Pass env as optional third param to querySqliteJson (backward compatible) - Add 16-test suite covering env-var acceptance, /tmp rejection, symlink traversal, NixOS paths, MacPorts, Windows fallback, and prefix safety --- src/web-server/usage/sqlite-cli.ts | 135 ++++++++- .../sqlite-cli-path-resolution.test.ts | 284 ++++++++++++++++++ 2 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 tests/unit/web-server/sqlite-cli-path-resolution.test.ts diff --git a/src/web-server/usage/sqlite-cli.ts b/src/web-server/usage/sqlite-cli.ts index ea09a1e0..ad4a151e 100644 --- a/src/web-server/usage/sqlite-cli.ts +++ b/src/web-server/usage/sqlite-cli.ts @@ -4,12 +4,45 @@ import { promisify } from 'util'; const execFileAsync = promisify(execFile); const SQLITE_JSON_MAX_BUFFER = 10 * 1024 * 1024; -const TRUSTED_SQLITE_PATHS = [ + +// Trusted system paths per platform. These are fixed, non-user-writable +// locations managed by the OS or a system package manager. +// PATH-hijack threat model: we never resolve from $PATH; we only accept +// binaries whose realpath resolves under one of these prefixes. +const TRUSTED_SQLITE_PATHS_UNIX = [ '/usr/bin/sqlite3', '/usr/local/bin/sqlite3', '/opt/homebrew/bin/sqlite3', ]; +// Windows has no single canonical system install path for sqlite3 +// (winget, Chocolatey, and Scoop all use different locations). An empty +// list means Windows falls through to the CCS_SQLITE_BIN env-var path. +const TRUSTED_SQLITE_PATHS_WINDOWS: string[] = []; + +// Trusted path prefixes used to validate env-var overrides. A realpath that +// does not start with one of these prefixes is rejected to prevent users or +// CI from pointing CCS_SQLITE_BIN at a writable/untrusted location. +const TRUSTED_PREFIX_UNIX = [ + '/usr/bin/', + '/usr/local/bin/', + '/usr/sbin/', + '/usr/local/sbin/', + '/opt/homebrew/', + '/opt/local/', // MacPorts + '/nix/store/', // Nix / NixOS immutable store + '/run/current-system/', // NixOS system activation symlink target + '/snap/', // Snap packages +]; + +const TRUSTED_PREFIX_WINDOWS = [ + 'C:\\Program Files\\', + 'C:\\Program Files (x86)\\', + 'C:\\Windows\\System32\\', + 'C:\\Windows\\SysWOW64\\', + 'C:\\ProgramData\\chocolatey\\bin\\', // Chocolatey managed bin dir +]; + export type SqliteJsonRow = Record; function isCommandMissing(error: unknown): boolean { @@ -18,10 +51,83 @@ function isCommandMissing(error: unknown): boolean { return nodeError.code === 'ENOENT' || /not found/i.test(nodeError.message); } -function resolveTrustedSqlitePath(): string { - const trustedPath = TRUSTED_SQLITE_PATHS.find((candidate) => { +function getPlatformTrustedPaths(): string[] { + return process.platform === 'win32' ? TRUSTED_SQLITE_PATHS_WINDOWS : TRUSTED_SQLITE_PATHS_UNIX; +} + +function getPlatformTrustedPrefixes(): string[] { + return process.platform === 'win32' ? TRUSTED_PREFIX_WINDOWS : TRUSTED_PREFIX_UNIX; +} + +/** + * Validate a CCS_SQLITE_BIN override path. + * + * Security invariant: the resolved (symlink-expanded) path must start with + * at least one trusted prefix. This prevents pointing at a binary in a + * user-writable location such as /tmp, $HOME/.local, or a relative PATH + * entry, which would reintroduce the PATH-hijack vector closed in #1347. + * + * Returns the validated path on success, or throws with an explanation. + */ +function validateEnvOverridePath(rawPath: string): string { + let resolved: string; + try { + resolved = fs.realpathSync(rawPath); + } catch { + throw new Error( + `CCS_SQLITE_BIN path "${rawPath}" could not be resolved: file not found or inaccessible` + ); + } + + // Verify executable bit (or file existence on Windows where X_OK is unreliable). + try { + if (process.platform === 'win32') { + fs.accessSync(resolved, fs.constants.F_OK); + } else { + fs.accessSync(resolved, fs.constants.X_OK); + } + } catch { + throw new Error(`CCS_SQLITE_BIN path "${resolved}" is not executable`); + } + + const normalizedResolved = process.platform === 'win32' ? resolved.toLowerCase() : resolved; + + const trusted = getPlatformTrustedPrefixes().some((prefix) => { + const normalizedPrefix = process.platform === 'win32' ? prefix.toLowerCase() : prefix; + return normalizedResolved.startsWith(normalizedPrefix); + }); + + if (!trusted) { + throw new Error( + `CCS_SQLITE_BIN path "${resolved}" does not resolve under a trusted system prefix. ` + + `Paths under user-writable locations (e.g. /tmp, $HOME/.local) are rejected ` + + `to prevent PATH-hijack attacks.` + ); + } + + return resolved; +} + +/** + * Resolve the sqlite3 binary to use. + * + * Resolution order: + * 1. CCS_SQLITE_BIN env var override (validated against trusted prefixes) + * 2. First accessible path from the platform's hardcoded trusted list + * 3. Throw "sqlite3 command not available" + */ +function resolveTrustedSqlitePath(env: NodeJS.ProcessEnv = process.env): string { + const envOverride = env['CCS_SQLITE_BIN']; + if (envOverride && envOverride.trim().length > 0) { + // May throw — caller surfaces the error. + return validateEnvOverridePath(envOverride.trim()); + } + + const trustedPath = getPlatformTrustedPaths().find((candidate) => { try { - fs.accessSync(candidate, fs.constants.X_OK); + // Resolve symlinks so the check is on the real binary. + const real = fs.realpathSync(candidate); + fs.accessSync(real, fs.constants.X_OK); return true; } catch { return false; @@ -32,16 +138,28 @@ function resolveTrustedSqlitePath(): string { throw new Error('sqlite3 command not available'); } - return trustedPath; + // Return the realpath to avoid double-hop symlink confusion at exec time. + return fs.realpathSync(trustedPath); } -export async function querySqliteJson(dbPath: string, sql: string): Promise { +export async function querySqliteJson( + dbPath: string, + sql: string, + env: NodeJS.ProcessEnv = process.env +): Promise { if (!fs.existsSync(dbPath)) { return []; } + let sqlitePath: string; + try { + sqlitePath = resolveTrustedSqlitePath(env); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(message); + } + try { - const sqlitePath = resolveTrustedSqlitePath(); const { stdout } = await execFileAsync(sqlitePath, ['-json', dbPath, sql], { maxBuffer: SQLITE_JSON_MAX_BUFFER, }); @@ -61,3 +179,6 @@ export async function querySqliteJson(dbPath: string, sql: string): Promise { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-sqlite-test-')); + }); + + afterEach(() => { + mock.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('rejects a path under /tmp (user-writable)', () => { + if (process.platform === 'win32') return; // skip on Windows + + const fakebin = makeTempExecutable(tempDir, 'sqlite3'); + + // Make realpathSync return the /tmp path itself + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => fakebin as string); + + expect(() => validateEnvOverridePath(fakebin)).toThrow( + /does not resolve under a trusted system prefix/ + ); + + realpathSpy.mockRestore(); + }); + + it('rejects a path under $HOME/.local', () => { + if (process.platform === 'win32') return; + + const homeLocalBin = path.join(os.homedir(), '.local', 'bin', 'sqlite3'); + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => homeLocalBin as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => validateEnvOverridePath(homeLocalBin)).toThrow( + /does not resolve under a trusted system prefix/ + ); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('accepts a path under /usr/bin (trusted Unix prefix)', () => { + if (process.platform === 'win32') return; + + const trustedPath = '/usr/bin/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => trustedPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => validateEnvOverridePath(trustedPath)).not.toThrow(); + const result = validateEnvOverridePath(trustedPath); + expect(result).toBe(trustedPath); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('accepts a NixOS-style path under /nix/store (immutable)', () => { + if (process.platform === 'win32') return; + + const nixPath = '/nix/store/abc123-sqlite-3.45.0/bin/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => nixPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => validateEnvOverridePath(nixPath)).not.toThrow(); + const result = validateEnvOverridePath(nixPath); + expect(result).toBe(nixPath); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('accepts a MacPorts path under /opt/local (trusted)', () => { + if (process.platform === 'win32') return; + + const macPortsPath = '/opt/local/bin/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => macPortsPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => validateEnvOverridePath(macPortsPath)).not.toThrow(); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('rejects a non-existent path', () => { + expect(() => validateEnvOverridePath('/nonexistent/path/to/sqlite3')).toThrow( + /could not be resolved/ + ); + }); + + it('resolves symlinks before checking prefix — rejects symlink pointing into /tmp', () => { + if (process.platform === 'win32') return; + + // Simulate: /usr/local/bin/sqlite3-link -> /tmp/evil-sqlite3 + const evilTarget = path.join(tempDir, 'evil-sqlite3'); + makeTempExecutable(tempDir, 'evil-sqlite3'); + + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => evilTarget as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + // The symlink source looks trusted, but the realpath (evilTarget in /tmp) is not + expect(() => validateEnvOverridePath('/usr/local/bin/sqlite3-link')).toThrow( + /does not resolve under a trusted system prefix/ + ); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveTrustedSqlitePath — env-var override +// --------------------------------------------------------------------------- + +describe('resolveTrustedSqlitePath with CCS_SQLITE_BIN', () => { + afterEach(() => { + mock.restore(); + }); + + it('uses CCS_SQLITE_BIN when set and valid', () => { + if (process.platform === 'win32') return; + + const nixPath = '/nix/store/xyz-sqlite/bin/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => nixPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + const result = resolveTrustedSqlitePath({ CCS_SQLITE_BIN: nixPath }); + expect(result).toBe(nixPath); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('throws when CCS_SQLITE_BIN points to /tmp', () => { + if (process.platform === 'win32') return; + + const tmpBin = '/tmp/sqlite3'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => tmpBin as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + expect(() => resolveTrustedSqlitePath({ CCS_SQLITE_BIN: tmpBin })).toThrow( + /does not resolve under a trusted system prefix/ + ); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('ignores CCS_SQLITE_BIN when set to empty string and falls through to platform list', () => { + if (process.platform === 'win32') return; + + // No accessible paths on platform list → should throw "not available" + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => { + throw new Error('ENOENT'); + }); + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => p as string); + + expect(() => resolveTrustedSqlitePath({ CCS_SQLITE_BIN: '' })).toThrow( + 'sqlite3 command not available' + ); + + accessSpy.mockRestore(); + realpathSpy.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveTrustedSqlitePath — platform trusted-path list +// --------------------------------------------------------------------------- + +describe('resolveTrustedSqlitePath platform path list', () => { + afterEach(() => { + mock.restore(); + }); + + it('returns the first accessible Unix trusted path', () => { + if (process.platform === 'win32') return; + + // Simulate /usr/bin/sqlite3 missing, /usr/local/bin/sqlite3 present + let callCount = 0; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => p as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => { + callCount++; + if (callCount === 1) throw new Error('ENOENT'); // /usr/bin/sqlite3 missing + // /usr/local/bin/sqlite3 accessible + }); + + const result = resolveTrustedSqlitePath({}); + expect(result).toBe('/usr/local/bin/sqlite3'); + + accessSpy.mockRestore(); + realpathSpy.mockRestore(); + }); + + it('throws "not available" when no trusted path exists and no env override', () => { + if (process.platform === 'win32') return; + + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => p as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => { + throw new Error('ENOENT'); + }); + + expect(() => resolveTrustedSqlitePath({})).toThrow('sqlite3 command not available'); + + accessSpy.mockRestore(); + realpathSpy.mockRestore(); + }); + + it('Windows: returns via CCS_SQLITE_BIN since hardcoded list is empty', () => { + if (process.platform !== 'win32') return; + + const winPath = 'C:\\Program Files\\SQLite\\sqlite3.exe'; + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation(() => winPath as string); + const accessSpy = spyOn(fs, 'accessSync').mockImplementation(() => undefined); + + const result = resolveTrustedSqlitePath({ CCS_SQLITE_BIN: winPath }); + expect(result.toLowerCase()).toContain('program files'); + + realpathSpy.mockRestore(); + accessSpy.mockRestore(); + }); + + it('Windows: throws "not available" when no env override provided', () => { + if (process.platform !== 'win32') return; + + // On Windows, TRUSTED_SQLITE_PATHS_WINDOWS is empty, so without env var it throws. + expect(() => resolveTrustedSqlitePath({})).toThrow('sqlite3 command not available'); + }); +}); + +// --------------------------------------------------------------------------- +// getPlatformTrustedPrefixes +// --------------------------------------------------------------------------- + +describe('getPlatformTrustedPrefixes', () => { + it('returns non-empty array', () => { + const prefixes = getPlatformTrustedPrefixes(); + expect(prefixes.length).toBeGreaterThan(0); + }); + + it('all prefixes end with a separator to prevent prefix-spoofing', () => { + // e.g. "/nix/store" without trailing slash would match "/nix/store-evil/" + const prefixes = getPlatformTrustedPrefixes(); + for (const prefix of prefixes) { + const endsWithSep = + process.platform === 'win32' ? prefix.endsWith('\\') : prefix.endsWith('/'); + expect(endsWithSep).toBe(true); + } + }); +});