mirror of https://github.com/garrytan/gstack.git
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:
parent
1b317aae9a
commit
37ef25c671
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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})`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue