mirror of https://github.com/garrytan/gstack.git
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:
parent
044c6d568e
commit
70e8056c8f
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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 ?? '');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue