diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index e094f3a5..190455d8 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 } from 'playwright'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; +import { validateNavigationUrl } from './url-validation'; export interface RefEntry { locator: Locator; @@ -111,6 +112,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 3c622db9..ab52e715 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'; @@ -219,6 +220,8 @@ export async function handleMetaCommand( case 'diff': { const [url1, url2] = args; if (!url1 || !url2) throw new Error('Usage: browse diff '); + validateNavigationUrl(url1); + validateNavigationUrl(url2); const page = bm.getPage(); await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); diff --git a/browse/src/url-validation.ts b/browse/src/url-validation.ts new file mode 100644 index 00000000..7f2e0ad9 --- /dev/null +++ b/browse/src/url-validation.ts @@ -0,0 +1,77 @@ +/** + * URL validation — prevent SSRF and local resource access + * + * Blocks file:/data:/javascript: schemes and private/internal IPs. + * Set BROWSE_ALLOW_PRIVATE=1 to bypass the private-IP check (local dev). + */ + +const ALLOWED_SCHEMES = new Set(['http:', 'https:']); + +const BLOCKED_HOSTNAMES = new Set(['localhost']); + +/** + * Check whether an IPv4 address is private/internal. + * Covers 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, + * 169.254.0.0/16 (link-local / cloud metadata), and 0.0.0.0. + */ +function isPrivateIP(hostname: string): boolean { + // Quick check for 0.0.0.0 + if (hostname === '0.0.0.0') return true; + + // Parse IPv4 octets + const parts = hostname.split('.'); + if (parts.length !== 4) return false; + + const octets = parts.map(Number); + if (octets.some(o => isNaN(o) || o < 0 || o > 255)) return false; + + const [a, b] = octets; + + if (a === 127) return true; // 127.0.0.0/8 + if (a === 10) return true; // 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 169 && b === 254) return true; // 169.254.0.0/16 + + return false; +} + +/** + * Validate a URL before navigating to it. + * Throws a descriptive error if the URL is blocked. + */ +export function validateNavigationUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + + // Scheme check + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + throw new Error( + `Blocked URL scheme "${parsed.protocol}" — only http: and https: are allowed. ` + + `Received: ${url}` + ); + } + + // Private/internal IP check (bypass with BROWSE_ALLOW_PRIVATE=1) + if (process.env.BROWSE_ALLOW_PRIVATE === '1') return; + + const hostname = parsed.hostname; + + if (BLOCKED_HOSTNAMES.has(hostname)) { + throw new Error( + `Blocked navigation to "${hostname}" — internal/private hosts are not allowed. ` + + `Set BROWSE_ALLOW_PRIVATE=1 to override for local development.` + ); + } + + if (isPrivateIP(hostname)) { + throw new Error( + `Blocked navigation to private IP "${hostname}" — internal network access is not allowed. ` + + `Set BROWSE_ALLOW_PRIVATE=1 to override for local development.` + ); + } +} 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})`;