Security: validate URLs to prevent SSRF and local resource access

Add url-validation.ts that blocks file:// URLs, private/internal IPs
(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,
::1, fc00::/7), and non-HTTP schemes before any page.goto() call.

Applied in all three navigation points: goto command, newTab(), and diff.
Bypass with BROWSE_ALLOW_PRIVATE=1 for local development.

Fixes #17

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederico Luz 2026-03-12 22:14:53 +00:00
parent 1b317aae9a
commit 37ef25c671
4 changed files with 82 additions and 0 deletions

View File

@ -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 });
}

View File

@ -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 <url1> <url2>');
validateUrl(url1);
validateUrl(url2);
// Get text from URL1
const page = bm.getPage();

View File

@ -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.`
);
}
}

View File

@ -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 <url>');
validateUrl(url);
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
const status = response?.status() || 'unknown';
return `Navigated to ${url} (${status})`;