Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
10 changes: 10 additions & 0 deletions browse/test/path-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down