Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -111,6 +112,7 @@ export class BrowserManager {
this.wirePageEvents(page);

if (url) {
validateNavigationUrl(url);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
}

Expand Down
3 changes: 3 additions & 0 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -219,6 +220,8 @@ export async function handleMetaCommand(
case 'diff': {
const [url1, url2] = args;
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
validateNavigationUrl(url1);
validateNavigationUrl(url2);

const page = bm.getPage();
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
Expand Down
77 changes: 77 additions & 0 deletions browse/src/url-validation.ts
Original file line number Diff line number Diff line change
@@ -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.`
);
}
}
2 changes: 2 additions & 0 deletions browse/src/write-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -21,6 +22,7 @@ export async function handleWriteCommand(
case 'goto': {
const url = args[0];
if (!url) throw new Error('Usage: browse goto <url>');
validateNavigationUrl(url);
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
const status = response?.status() || 'unknown';
return `Navigated to ${url} (${status})`;
Expand Down