Fix path traversal in screenshot/pdf/eval commands

screenshot, pdf, and responsive accepted arbitrary output paths,
allowing writes to any location on disk. eval read arbitrary files
without restriction. Add path validation:

- Output paths (screenshot/pdf/responsive): restricted to /tmp or cwd
- Input paths (eval): restricted to cwd only
- Rejects traversal via .. after path resolution

Closes #13

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
oliursahin 2026-03-13 00:04:54 +05:30
parent 044c6d568e
commit 70e8056c8f
4 changed files with 96 additions and 4 deletions

View File

@ -4,6 +4,7 @@
import type { BrowserManager } from './browser-manager';
import { handleSnapshot } from './snapshot';
import { validateOutputPath } from './path-validation';
import * as Diff from 'diff';
import * as fs from 'fs';
@ -72,14 +73,14 @@ export async function handleMetaCommand(
// ─── Visual ────────────────────────────────────────
case 'screenshot': {
const page = bm.getPage();
const screenshotPath = args[0] || '/tmp/browse-screenshot.png';
const screenshotPath = validateOutputPath(args[0] || '/tmp/browse-screenshot.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
return `Screenshot saved: ${screenshotPath}`;
}
case 'pdf': {
const page = bm.getPage();
const pdfPath = args[0] || '/tmp/browse-page.pdf';
const pdfPath = validateOutputPath(args[0] || '/tmp/browse-page.pdf');
await page.pdf({ path: pdfPath, format: 'A4' });
return `PDF saved: ${pdfPath}`;
}
@ -87,6 +88,7 @@ export async function handleMetaCommand(
case 'responsive': {
const page = bm.getPage();
const prefix = args[0] || '/tmp/browse-responsive';
validateOutputPath(prefix);
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },

View File

@ -0,0 +1,44 @@
/**
* Path validation utilities to prevent path traversal attacks.
*
* Output paths (screenshot, pdf, responsive) are restricted to /tmp or
* the current working directory tree.
*
* Input paths (eval) are restricted to the current working directory tree.
*/
import * as path from 'path';
const ALLOWED_OUTPUT_ROOTS = ['/tmp'];
function resolveAndCheck(filePath: string, allowedRoots: string[]): string {
const resolved = path.resolve(filePath);
for (const root of allowedRoots) {
if (resolved.startsWith(root + '/') || resolved === root) {
return resolved;
}
}
const allowed = allowedRoots.map(r => `"${r}"`).join(', ');
throw new Error(
`Path "${filePath}" resolves outside allowed directories (${allowed}). ` +
`Use a path under one of these directories.`
);
}
/**
* Validate an output file path (screenshot, pdf, responsive).
* Allowed: /tmp/*, cwd/*
*/
export function validateOutputPath(filePath: string): string {
const cwd = process.cwd();
return resolveAndCheck(filePath, [...ALLOWED_OUTPUT_ROOTS, cwd]);
}
/**
* Validate an input file path (eval).
* Allowed: cwd/* only prevents reading arbitrary files like /etc/passwd.
*/
export function validateInputPath(filePath: string): string {
const cwd = process.cwd();
return resolveAndCheck(filePath, [cwd]);
}

View File

@ -7,6 +7,7 @@
import type { BrowserManager } from './browser-manager';
import { consoleBuffer, networkBuffer } from './buffers';
import { validateInputPath } from './path-validation';
import * as fs from 'fs';
export async function handleReadCommand(
@ -98,8 +99,9 @@ export async function handleReadCommand(
case 'eval': {
const filePath = args[0];
if (!filePath) throw new Error('Usage: browse eval <js-file>');
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
const code = fs.readFileSync(filePath, 'utf-8');
const safePath = validateInputPath(filePath);
if (!fs.existsSync(safePath)) throw new Error(`File not found: ${safePath}`);
const code = fs.readFileSync(safePath, 'utf-8');
const result = await page.evaluate(code);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}

View File

@ -0,0 +1,44 @@
import { describe, it, expect } from 'bun:test';
import { validateOutputPath, validateInputPath } from '../src/path-validation';
describe('validateOutputPath', () => {
it('allows /tmp paths', () => {
expect(validateOutputPath('/tmp/screenshot.png')).toBe('/tmp/screenshot.png');
expect(validateOutputPath('/tmp/nested/dir/file.pdf')).toBe('/tmp/nested/dir/file.pdf');
});
it('allows cwd paths', () => {
const cwd = process.cwd();
expect(validateOutputPath('./output.png')).toBe(`${cwd}/output.png`);
});
it('rejects paths outside allowed directories', () => {
expect(() => validateOutputPath('/etc/cron.d/backdoor.png')).toThrow('resolves outside allowed directories');
expect(() => validateOutputPath('/var/log/evil.pdf')).toThrow('resolves outside allowed directories');
expect(() => validateOutputPath('/home/user/.ssh/key')).toThrow('resolves outside allowed directories');
});
it('rejects path traversal via ..', () => {
expect(() => validateOutputPath('/tmp/../../etc/passwd')).toThrow('resolves outside allowed directories');
});
});
describe('validateInputPath', () => {
it('allows cwd paths', () => {
const cwd = process.cwd();
expect(validateInputPath('./test.js')).toBe(`${cwd}/test.js`);
});
it('rejects /tmp paths', () => {
expect(() => validateInputPath('/tmp/evil.js')).toThrow('resolves outside allowed directories');
});
it('rejects absolute paths outside cwd', () => {
expect(() => validateInputPath('/etc/passwd')).toThrow('resolves outside allowed directories');
expect(() => validateInputPath('/home/user/.ssh/id_rsa')).toThrow('resolves outside allowed directories');
});
it('rejects path traversal via ..', () => {
expect(() => validateInputPath('../../../etc/passwd')).toThrow('resolves outside allowed directories');
});
});