Files
gstack/browse/test/temp-dirs.test.ts
T
Garry TanandClaude Fable 5 5ed46193f1 fix(browse): an untrustable TMPDIR (/, $HOME, a cwd ancestor) never widens the local allowlist
TEMP_DIRS honors os.tmpdir() at daemon start; a daemon launched with
TMPDIR=/ would have trusted the whole filesystem for local path validation
for its lifetime. Subprocess pins cover /, $HOME, cwd-ancestor rejection and
that a benign distinct TMPDIR (the sandbox recipe's $HOME/tmp) stays honored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 05:58:14 +00:00

103 lines
4.5 KiB
TypeScript

/**
* TEMP_DIRS portability allowlist (platform.ts) and its path-security wiring.
*
* TEMP_DIRS widens LOCAL path validation to include os.tmpdir() — on macOS
* that is the per-user /var/folders dir, and TMPDIR-honoring CI/sandbox
* environments point it elsewhere entirely. The load-bearing SECURITY
* invariant is asymmetry: SAFE_DIRECTORIES (local read/write) gains
* os.tmpdir(), while validateTempPath (REMOTE file serving, GET /file) stays
* pinned to classic TEMP_DIR alone — widening that one would let a tunnel
* client fetch files from an arbitrary TMPDIR (e.g. $HOME/tmp).
*/
import { describe, it, expect } from 'bun:test';
import { TEMP_DIR, TEMP_DIRS } from '../src/platform';
import { SAFE_DIRECTORIES, validateOutputPath, validateReadPath, validateTempPath } from '../src/path-security';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const real = (p: string) => { try { return fs.realpathSync(p); } catch { return p; } };
const tmpdirIsDistinct = real(os.tmpdir()) !== real(TEMP_DIR);
describe('TEMP_DIRS allowlist shape', () => {
it('contains exactly TEMP_DIR + os.tmpdir() deduped, and SAFE_DIRECTORIES wires them realpathed alongside cwd', () => {
expect(TEMP_DIRS).toContain(TEMP_DIR);
expect(TEMP_DIRS).toContain(os.tmpdir());
expect(new Set(TEMP_DIRS).size).toBe(TEMP_DIRS.length);
// Nothing else sneaks into the allowlist.
for (const d of TEMP_DIRS) {
expect([TEMP_DIR, os.tmpdir()]).toContain(d);
}
expect(SAFE_DIRECTORIES).toContain(real(os.tmpdir()));
expect(SAFE_DIRECTORIES).toContain(real(process.cwd()));
});
});
describe('local commands accept os.tmpdir() paths (TMPDIR-honoring environments)', () => {
it('validateReadPath allows an existing file under os.tmpdir()', () => {
const f = path.join(os.tmpdir(), `browse-tempdirs-read-${Date.now()}.js`);
fs.writeFileSync(f, 'document.title');
try {
expect(() => validateReadPath(f)).not.toThrow();
} finally {
fs.unlinkSync(f);
}
});
it('validateOutputPath allows a new (not-yet-existing) file under os.tmpdir()', () => {
const f = path.join(os.tmpdir(), `browse-tempdirs-out-${Date.now()}.png`);
expect(() => validateOutputPath(f)).not.toThrow();
expect(fs.existsSync(f)).toBe(false); // validation never creates the file
});
});
describe('remote file serving stays pinned to TEMP_DIR alone (no exfil widening)', () => {
it('validateTempPath REJECTS a file under a distinct os.tmpdir() — local-only allowlist never reaches the remote surface', () => {
if (!tmpdirIsDistinct) {
// On hosts where os.tmpdir() IS /tmp the asymmetry is untestable this
// way — but then the allowlist must have collapsed to the single dir.
expect(TEMP_DIRS).toEqual([TEMP_DIR]);
return;
}
const f = path.join(os.tmpdir(), `browse-tempdirs-remote-${Date.now()}.txt`);
fs.writeFileSync(f, 'served?');
try {
// Same file: fine for local commands, forbidden for remote serving.
expect(() => validateReadPath(f)).not.toThrow();
expect(() => validateTempPath(f)).toThrow(/temp directory/i);
} finally {
fs.unlinkSync(f);
}
});
});
describe('untrustable TMPDIR values never widen the allowlist', () => {
// TEMP_DIRS is computed at module load from os.tmpdir(), which honors
// TMPDIR — so a daemon launched with TMPDIR=/ or TMPDIR=$HOME must not
// trust that subtree for its whole lifetime. Probed via a subprocess so
// each case gets a fresh module load.
const probe = (tmpdir: string): string[] => {
const r = Bun.spawnSync([
process.execPath, '-e',
"import { TEMP_DIRS } from './browse/src/platform'; console.log(JSON.stringify(TEMP_DIRS));",
], { env: { ...process.env, TMPDIR: tmpdir }, cwd: path.resolve(import.meta.dir, '..', '..') });
return JSON.parse(r.stdout.toString().trim().split('\n').pop()!);
};
it('TMPDIR=/ and TMPDIR=$HOME collapse to TEMP_DIR alone; a cwd ancestor is rejected too', () => {
expect(probe('/')).toEqual([TEMP_DIR]);
expect(probe(os.homedir())).toEqual([TEMP_DIR]);
// Parent of the daemon cwd (the repo checkout's parent) — rejected.
expect(probe(path.resolve(import.meta.dir, '..', '..', '..'))).toEqual([TEMP_DIR]);
});
it('a benign distinct TMPDIR (e.g. $HOME/tmp) is still honored for local paths', () => {
const benign = fs.mkdtempSync(path.join(os.homedir(), 'browse-tmp-probe-'));
try {
expect(probe(benign)).toContain(fs.realpathSync(benign));
} finally {
fs.rmdirSync(benign);
}
});
});