From 56c8c994bf0e7b9d17598058241fd461e34709b1 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 18 Mar 2026 23:27:27 -0700 Subject: [PATCH] fix: block SSRF via URL validation in browse commands (#17) Adds validateNavigationUrl() that blocks non-HTTP(S) schemes (file://, javascript:, data:) and cloud metadata endpoints (169.254.169.254, metadata.google.internal). Applied to goto, diff, and newTab commands. Localhost and private IPs remain allowed for local dev QA. Closes #17 Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/browser-manager.ts | 2 ++ browse/src/meta-commands.ts | 5 +++- browse/src/url-validation.ts | 32 ++++++++++++++++++++ browse/src/write-commands.ts | 2 ++ browse/test/url-validation.test.ts | 48 ++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 browse/src/url-validation.ts create mode 100644 browse/test/url-validation.test.ts diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 24cfda64..bf26ede6 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -17,6 +17,7 @@ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; +import { validateNavigationUrl } from './url-validation'; export interface RefEntry { locator: Locator; @@ -128,6 +129,7 @@ export class BrowserManager { this.wirePageEvents(page); if (url) { + validateNavigationUrl(url); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); } diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index e628d6a3..049ed69a 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -6,6 +6,7 @@ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; import { getCleanText } from './read-commands'; import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; +import { validateNavigationUrl } from './url-validation'; import * as Diff from 'diff'; import * as fs from 'fs'; import * as path from 'path'; @@ -13,7 +14,7 @@ import * as path from 'path'; // Security: Path validation to prevent path traversal attacks const SAFE_DIRECTORIES = ['/tmp', process.cwd()]; -function validateOutputPath(filePath: string): void { +export function validateOutputPath(filePath: string): void { const resolved = path.resolve(filePath); const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/')); if (!isSafe) { @@ -221,9 +222,11 @@ export async function handleMetaCommand( if (!url1 || !url2) throw new Error('Usage: browse diff '); const page = bm.getPage(); + validateNavigationUrl(url1); await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); const text1 = await getCleanText(page); + validateNavigationUrl(url2); await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); const text2 = await getCleanText(page); diff --git a/browse/src/url-validation.ts b/browse/src/url-validation.ts new file mode 100644 index 00000000..7b181320 --- /dev/null +++ b/browse/src/url-validation.ts @@ -0,0 +1,32 @@ +/** + * URL validation for navigation commands — blocks dangerous schemes and cloud metadata endpoints. + * Localhost and private IPs are allowed (primary use case: QA testing local dev servers). + */ + +const BLOCKED_METADATA_HOSTS = [ + '169.254.169.254', // AWS/GCP/Azure instance metadata + 'fd00::', // IPv6 unique local (metadata in some cloud setups) + 'metadata.google.internal', // GCP metadata +]; + +export function validateNavigationUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error( + `Blocked: scheme "${parsed.protocol}" is not allowed. Only http: and https: URLs are permitted.` + ); + } + + const hostname = parsed.hostname.toLowerCase(); + if (BLOCKED_METADATA_HOSTS.includes(hostname)) { + throw new Error( + `Blocked: ${hostname} is a cloud metadata endpoint. Access is denied for security.` + ); + } +} diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 2b384920..26a46a4b 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -7,6 +7,7 @@ import type { BrowserManager } from './browser-manager'; import { findInstalledBrowsers, importCookies } from './cookie-import-browser'; +import { validateNavigationUrl } from './url-validation'; import * as fs from 'fs'; import * as path from 'path'; @@ -21,6 +22,7 @@ export async function handleWriteCommand( case 'goto': { const url = args[0]; if (!url) throw new Error('Usage: browse goto '); + validateNavigationUrl(url); const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); const status = response?.status() || 'unknown'; return `Navigated to ${url} (${status})`; diff --git a/browse/test/url-validation.test.ts b/browse/test/url-validation.test.ts new file mode 100644 index 00000000..529ca95d --- /dev/null +++ b/browse/test/url-validation.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'bun:test'; +import { validateNavigationUrl } from '../src/url-validation'; + +describe('validateNavigationUrl', () => { + it('allows http URLs', () => { + expect(() => validateNavigationUrl('http://example.com')).not.toThrow(); + }); + + it('allows https URLs', () => { + expect(() => validateNavigationUrl('https://example.com/path?q=1')).not.toThrow(); + }); + + it('allows localhost', () => { + expect(() => validateNavigationUrl('http://localhost:3000')).not.toThrow(); + }); + + it('allows 127.0.0.1', () => { + expect(() => validateNavigationUrl('http://127.0.0.1:8080')).not.toThrow(); + }); + + it('allows private IPs', () => { + expect(() => validateNavigationUrl('http://192.168.1.1')).not.toThrow(); + }); + + it('blocks file:// scheme', () => { + expect(() => validateNavigationUrl('file:///etc/passwd')).toThrow(/scheme.*not allowed/i); + }); + + it('blocks javascript: scheme', () => { + expect(() => validateNavigationUrl('javascript:alert(1)')).toThrow(/scheme.*not allowed/i); + }); + + it('blocks data: scheme', () => { + expect(() => validateNavigationUrl('data:text/html,

hi

')).toThrow(/scheme.*not allowed/i); + }); + + it('blocks AWS/GCP metadata endpoint', () => { + expect(() => validateNavigationUrl('http://169.254.169.254/latest/meta-data/')).toThrow(/cloud metadata/i); + }); + + it('blocks GCP metadata hostname', () => { + expect(() => validateNavigationUrl('http://metadata.google.internal/computeMetadata/v1/')).toThrow(/cloud metadata/i); + }); + + it('throws on malformed URLs', () => { + expect(() => validateNavigationUrl('not-a-url')).toThrow(/Invalid URL/i); + }); +});