Files
ccs/tests/unit/web-server/sqlite-cli-path-resolution.test.ts
T
Kai (Tam Nhu) TranandGitHub 9a420988bc 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
2026-05-23 17:29:52 -04:00

285 lines
10 KiB
TypeScript

/**
* 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);
}
});
});