diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 0fbe9aea4..652e074f2 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -6,6 +6,22 @@ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; import * as Diff from 'diff'; import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Validates that a file path is within an allowed directory to prevent path traversal attacks. + * Allowed directories: /tmp and the current working directory. + */ +function validateOutputPath(filePath: string): void { + const resolvedPath = path.resolve(filePath); + const tmpDir = '/tmp'; + const cwd = process.cwd(); + + // Allow paths in /tmp or under CWD + if (!resolvedPath.startsWith(tmpDir + '/') && !resolvedPath.startsWith(cwd + '/')) { + throw new Error(`Security: Output path must be within /tmp or the current working directory. Received: ${filePath}`); + } +} export async function handleMetaCommand( command: string, @@ -73,6 +89,7 @@ export async function handleMetaCommand( case 'screenshot': { const page = bm.getPage(); const screenshotPath = args[0] || '/tmp/browse-screenshot.png'; + validateOutputPath(screenshotPath); await page.screenshot({ path: screenshotPath, fullPage: true }); return `Screenshot saved: ${screenshotPath}`; } @@ -80,6 +97,7 @@ export async function handleMetaCommand( case 'pdf': { const page = bm.getPage(); const pdfPath = args[0] || '/tmp/browse-page.pdf'; + validateOutputPath(pdfPath); await page.pdf({ path: pdfPath, format: 'A4' }); return `PDF saved: ${pdfPath}`; } @@ -87,6 +105,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 }, diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index a473477d0..741b8077c 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -8,6 +8,27 @@ import type { BrowserManager } from './browser-manager'; import { consoleBuffer, networkBuffer } from './buffers'; import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Validates that a file path is within allowed directories to prevent path traversal. + * Allowed directories: /tmp, CWD, and subdirectories of CWD. + */ +function validateFilePath(filePath: string): void { + // Reject absolute paths outside /tmp + if (path.isAbsolute(filePath) && !filePath.startsWith('/tmp/')) { + throw new Error(`Security: Absolute paths must be within /tmp. Received: ${filePath}`); + } + + // Resolve the path and check for traversal + const resolvedPath = path.resolve(filePath); + const cwd = process.cwd(); + const tmpDir = '/tmp'; + + if (!resolvedPath.startsWith(tmpDir + '/') && !resolvedPath.startsWith(cwd + '/')) { + throw new Error(`Security: File must be within /tmp or the current working directory. Received: ${filePath}`); + } +} export async function handleReadCommand( command: string, @@ -98,6 +119,7 @@ export async function handleReadCommand( case 'eval': { const filePath = args[0]; if (!filePath) throw new Error('Usage: browse eval '); + validateFilePath(filePath); if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); const code = fs.readFileSync(filePath, 'utf-8'); const result = await page.evaluate(code);