diff --git a/browse/SKILL.md b/browse/SKILL.md index b752aec..b80e251 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -1,6 +1,7 @@ --- name: browse version: 1.0.0 +model: sonnet description: | Fast web browsing for Claude Code via persistent headless Chromium daemon. Navigate to any URL, read page content, click elements, fill forms, run JavaScript, take screenshots, diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 033ed87..6c1ef3a 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-validator'; 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 0fbe9ae..d72345d 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -4,8 +4,25 @@ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; +import { validateUrl } from './url-validator'; import * as Diff from 'diff'; import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Validates that a file path is within an allowed directory to prevent path traversal attacks. + * Allowed directories: /tmp and the current working directory. + */ +function validateOutputPath(filePath: string): void { + const resolvedPath = path.resolve(filePath); + const tmpDir = '/tmp'; + const cwd = process.cwd(); + + // Allow paths in /tmp or under CWD + if (!resolvedPath.startsWith(tmpDir + '/') && !resolvedPath.startsWith(cwd + '/')) { + throw new Error(`Security: Output path must be within /tmp or the current working directory. Received: ${filePath}`); + } +} export async function handleMetaCommand( command: string, @@ -73,6 +90,7 @@ export async function handleMetaCommand( case 'screenshot': { const page = bm.getPage(); const screenshotPath = args[0] || '/tmp/browse-screenshot.png'; + validateOutputPath(screenshotPath); await page.screenshot({ path: screenshotPath, fullPage: true }); return `Screenshot saved: ${screenshotPath}`; } @@ -80,6 +98,7 @@ export async function handleMetaCommand( case 'pdf': { const page = bm.getPage(); const pdfPath = args[0] || '/tmp/browse-page.pdf'; + validateOutputPath(pdfPath); await page.pdf({ path: pdfPath, format: 'A4' }); return `PDF saved: ${pdfPath}`; } @@ -87,6 +106,7 @@ export async function handleMetaCommand( case 'responsive': { const page = bm.getPage(); const prefix = args[0] || '/tmp/browse-responsive'; + validateOutputPath(prefix); const viewports = [ { name: 'mobile', width: 375, height: 812 }, { name: 'tablet', width: 768, height: 1024 }, @@ -153,6 +173,10 @@ export async function handleMetaCommand( const [url1, url2] = args; if (!url1 || !url2) throw new Error('Usage: browse diff '); + // Validate both URLs + validateUrl(url1); + validateUrl(url2); + // Get text from URL1 const page = bm.getPage(); await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index a473477..741b807 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -8,6 +8,27 @@ import type { BrowserManager } from './browser-manager'; import { consoleBuffer, networkBuffer } from './buffers'; import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Validates that a file path is within allowed directories to prevent path traversal. + * Allowed directories: /tmp, CWD, and subdirectories of CWD. + */ +function validateFilePath(filePath: string): void { + // Reject absolute paths outside /tmp + if (path.isAbsolute(filePath) && !filePath.startsWith('/tmp/')) { + throw new Error(`Security: Absolute paths must be within /tmp. Received: ${filePath}`); + } + + // Resolve the path and check for traversal + const resolvedPath = path.resolve(filePath); + const cwd = process.cwd(); + const tmpDir = '/tmp'; + + if (!resolvedPath.startsWith(tmpDir + '/') && !resolvedPath.startsWith(cwd + '/')) { + throw new Error(`Security: File must be within /tmp or the current working directory. Received: ${filePath}`); + } +} export async function handleReadCommand( command: string, @@ -98,6 +119,7 @@ export async function handleReadCommand( case 'eval': { const filePath = args[0]; if (!filePath) throw new Error('Usage: browse eval '); + validateFilePath(filePath); if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); const code = fs.readFileSync(filePath, 'utf-8'); const result = await page.evaluate(code); diff --git a/browse/src/url-validator.ts b/browse/src/url-validator.ts new file mode 100644 index 0000000..85fff2d --- /dev/null +++ b/browse/src/url-validator.ts @@ -0,0 +1,131 @@ +/** + * URL validation utilities to prevent SSRF and local resource access attacks. + */ + +import { URL } from 'url'; + +/** + * Private network IP ranges that should be blocked by default. + */ +const BLOCKED_HOSTS = new Set([ + 'localhost', + '127.0.0.1', + '::1', + '0.0.0.0', +]); + +/** + * Private IP ranges (RFC1918). + */ +const BLOCKED_RANGES = [ + { start: '10.0.0.0', end: '10.255.255.255' }, + { start: '172.16.0.0', end: '172.31.255.255' }, + { start: '192.168.0.0', end: '192.168.255.255' }, +]; + +/** + * Cloud metadata IP addresses. + */ +const METADATA_IPS = new Set([ + '169.254.169.254', // AWS, GCP, Azure + '169.254.169.253', // AWS + '169.254.169.249', // Azure + 'metadata.google.internal', // GCP +]); + +/** + * Checks if an IP is within a blocked range. + */ +function isIPInRange(ip: string, range: { start: string; end: string }): boolean { + const ipNum = ipToNumber(ip); + if (ipNum === null) return false; + const startNum = ipToNumber(range.start); + const endNum = ipToNumber(range.end); + if (startNum === null || endNum === null) return false; + return ipNum >= startNum && ipNum <= endNum; +} + +/** + * Converts IP string to number for range comparison. + */ +function ipToNumber(ip: string): number | null { + const parts = ip.split('.'); + if (parts.length !== 4) return null; + let result = 0; + for (let i = 0; i < 4; i++) { + const part = parseInt(parts[i], 10); + if (isNaN(part) || part < 0 || part > 255) return null; + result = result * 256 + part; + } + return result; +} + +/** + * Validates a URL for SSRF and local resource access. + * @param urlString - The URL to validate + * @param allowPrivate - Whether to allow private network access (default: false) + * @param allowFile - Whether to allow file:// URLs (default: false) + * @throws Error if the URL is blocked + */ +export function validateUrl( + urlString: string, + allowPrivate: boolean = false, + allowFile: boolean = false +): void { + // Handle file:// URLs + if (urlString.startsWith('file://')) { + if (!allowFile) { + throw new Error('Security: file:// URLs are not allowed by default. Use --allow-file to enable.'); + } + return; + } + + let url: URL; + try { + url = new URL(urlString); + } catch { + throw new Error(`Security: Invalid URL format: ${urlString}`); + } + + const scheme = url.protocol.toLowerCase(); + + // Block non-http(s) schemes unless explicitly allowed + if (scheme !== 'http:' && scheme !== 'https:') { + throw new Error(`Security: Only http:// and https:// URLs are allowed. Got: ${scheme}`); + } + + const hostname = url.hostname.toLowerCase(); + + // Block blocked hosts + if (BLOCKED_HOSTS.has(hostname)) { + throw new Error(`Security: Access to localhost/loopback is not allowed. Host: ${hostname}`); + } + + // Block cloud metadata endpoints + if (METADATA_IPS.has(hostname)) { + throw new Error(`Security: Cloud metadata endpoints are not allowed. Host: ${hostname}`); + } + + // Check if hostname is an IP + const ipNum = ipToNumber(hostname); + if (ipNum !== null) { + // Block link-local (169.254.x.x) + if (ipNum >= 0xA9FE0000 && ipNum <= 0xA9FEFFFF) { + throw new Error(`Security: Link-local addresses are not allowed. Host: ${hostname}`); + } + + // Block private ranges unless explicitly allowed + if (!allowPrivate) { + for (const range of BLOCKED_RANGES) { + if (isIPInRange(hostname, range)) { + throw new Error(`Security: Private network access is not allowed. Host: ${hostname}`); + } + } + } + } + + // Block .internal, .localhost, etc. + if (hostname.endsWith('.internal') || hostname.endsWith('.localhost')) { + throw new Error(`Security: Internal/reserved hostnames are not allowed. Host: ${hostname}`); + } +} diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index e1c9194..fa25ffd 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-validator'; 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})`;