diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 033ed8747..cbce2bdad 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -9,6 +9,7 @@ import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright'; import { addConsoleEntry, addNetworkEntry, networkBuffer, type LogEntry, type NetworkEntry } from './buffers'; +import { validateUrl } from './url-validation'; export class BrowserManager { private browser: Browser | null = null; @@ -66,6 +67,7 @@ export class BrowserManager { this.wirePageEvents(page); if (url) { + validateUrl(url); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); } diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 0fbe9aea4..5f7608b0a 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -4,6 +4,7 @@ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; +import { validateUrl } from './url-validation'; import * as Diff from 'diff'; import * as fs from 'fs'; @@ -152,6 +153,8 @@ export async function handleMetaCommand( case 'diff': { const [url1, url2] = args; if (!url1 || !url2) throw new Error('Usage: browse diff '); + validateUrl(url1); + validateUrl(url2); // Get text from URL1 const page = bm.getPage(); diff --git a/browse/src/url-validation.ts b/browse/src/url-validation.ts new file mode 100644 index 000000000..d2001b56e --- /dev/null +++ b/browse/src/url-validation.ts @@ -0,0 +1,75 @@ +/** + * URL validation to prevent SSRF and local resource access. + * + * Blocks file:// URLs, private/internal IPs, and non-HTTP schemes. + * Set BROWSE_ALLOW_PRIVATE=1 to bypass for local development. + */ + +const ALLOWED_SCHEMES = new Set(['http:', 'https:']); + +/** + * Check if a hostname resolves to a private/internal IP address. + */ +function isPrivateHost(hostname: string): boolean { + // IPv6 loopback + if (hostname === '[::1]' || hostname === '::1') return true; + + // Strip brackets from IPv6 + const bare = hostname.replace(/^\[|\]$/g, ''); + + // IPv6 unique-local (fc00::/7) + if (/^f[cd][0-9a-f]{2}:/i.test(bare)) return true; + + // IPv4 checks + const parts = bare.split('.').map(Number); + if (parts.length === 4 && parts.every(p => !isNaN(p))) { + const [a, b] = parts; + // 127.0.0.0/8 + if (a === 127) return true; + // 10.0.0.0/8 + if (a === 10) return true; + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) return true; + // 192.168.0.0/16 + if (a === 192 && b === 168) return true; + // 169.254.0.0/16 (link-local / cloud metadata) + if (a === 169 && b === 254) return true; + // 0.0.0.0 + if (a === 0 && b === 0 && parts[2] === 0 && parts[3] === 0) return true; + } + + // "localhost" variants + if (bare === 'localhost' || bare.endsWith('.localhost')) return true; + + return false; +} + +/** + * Validate a URL before navigating. Throws if the URL is not allowed. + */ +export function validateUrl(url: string): void { + // Bypass when explicitly opted in for local development + if (process.env.BROWSE_ALLOW_PRIVATE === '1') return; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error( + `Invalid URL: "${url}". Only http: and https: URLs are allowed.` + ); + } + + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + throw new Error( + `Blocked URL scheme "${parsed.protocol}" in "${url}". Only http: and https: are allowed.` + ); + } + + if (isPrivateHost(parsed.hostname)) { + throw new Error( + `Blocked navigation to private/internal address "${parsed.hostname}". ` + + `Set BROWSE_ALLOW_PRIVATE=1 to allow local development URLs.` + ); + } +} diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index e1c91942b..3bae0c73c 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -6,6 +6,7 @@ */ import type { BrowserManager } from './browser-manager'; +import { validateUrl } from './url-validation'; export async function handleWriteCommand( command: string, @@ -18,6 +19,7 @@ export async function handleWriteCommand( case 'goto': { const url = args[0]; if (!url) throw new Error('Usage: browse goto '); + validateUrl(url); const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); const status = response?.status() || 'unknown'; return `Navigated to ${url} (${status})`;