mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
fix(analytics): support sqlite3 path resolution on Windows and NixOS (#1354)
- 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
This commit is contained in:
@@ -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<string, unknown>;
|
||||
|
||||
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<SqliteJsonRow[]> {
|
||||
export async function querySqliteJson(
|
||||
dbPath: string,
|
||||
sql: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<SqliteJsonRow[]> {
|
||||
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<Sqli
|
||||
throw new Error(`sqlite3 query failed for ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export internals for unit testing — not part of the public API.
|
||||
export { resolveTrustedSqlitePath, validateEnvOverridePath, getPlatformTrustedPrefixes };
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Tests for sqlite-cli.ts cross-platform path resolution and CCS_SQLITE_BIN
|
||||
* env-var override.
|
||||
*
|
||||
* Threat model (PR #1347 follow-up): PATH-hijack via a writable PATH entry.
|
||||
* The env-var escape hatch must NOT reintroduce that vector: only binaries
|
||||
* whose realpath resolves under a trusted system prefix are accepted.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
getPlatformTrustedPrefixes,
|
||||
resolveTrustedSqlitePath,
|
||||
validateEnvOverridePath,
|
||||
} from '../../../src/web-server/usage/sqlite-cli';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTempExecutable(dir: string, name: string): string {
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, '#!/bin/sh\nexec sqlite3 "$@"\n', { mode: 0o755 });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateEnvOverridePath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateEnvOverridePath', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user