diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index e2060c214..2f78ee9a9 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -19,10 +19,33 @@ const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()]; export function validateOutputPath(filePath: string): void { const resolved = path.resolve(filePath); + + // Lexical containment check: catches obvious traversal (../../../etc/passwd) const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); if (!isSafe) { throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); } + + // Symlink check: resolve the real path of the nearest existing ancestor + // directory and re-validate. Closes the symlink bypass where a symlink + // inside /tmp or cwd points outside the safe zone. + let dir = path.dirname(resolved); + let realDir: string; + try { + realDir = fs.realpathSync(dir); + } catch { + try { + realDir = fs.realpathSync(path.dirname(dir)); + } catch { + throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); + } + } + + const realResolved = path.join(realDir, path.basename(resolved)); + const isRealSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realResolved, dir)); + if (!isRealSafe) { + throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')} (symlink target blocked)`); + } } /** Tokenize a pipe segment respecting double-quoted strings. */ diff --git a/browse/test/path-validation.test.ts b/browse/test/path-validation.test.ts index 8a26436ca..73f686f99 100644 --- a/browse/test/path-validation.test.ts +++ b/browse/test/path-validation.test.ts @@ -33,6 +33,16 @@ describe('validateOutputPath', () => { it('blocks path traversal via ..', () => { expect(() => validateOutputPath('/tmp/../etc/passwd')).toThrow(/Path must be within/); }); + + it('blocks symlink inside safe dir pointing outside', () => { + const linkPath = join(tmpdir(), 'test-output-symlink-' + Date.now()); + try { + symlinkSync('/etc', linkPath); + expect(() => validateOutputPath(join(linkPath, 'passwd'))).toThrow(/Path must be within/); + } finally { + try { unlinkSync(linkPath); } catch {} + } + }); }); describe('validateReadPath', () => {