From 15345a42ec4ea62fe5590dd838d6f61de1156695 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Fri, 13 Mar 2026 03:21:06 +0800 Subject: [PATCH 1/3] Security: Fix path traversal in screenshot/pdf/eval commands - Add validateOutputPath() to restrict output paths to /tmp or CWD - Add validateFilePath() to prevent arbitrary file read via eval - Resolves issue #13 (path traversal allows arbitrary file read/write) --- browse/src/meta-commands.ts | 19 +++++++++++++++++++ browse/src/read-commands.ts | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 0fbe9ae..652e074 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -6,6 +6,22 @@ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; 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 +89,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 +97,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 +105,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 }, 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); From 907c67e9bf93227e09141f72f949e9fe70f48cd7 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Fri, 13 Mar 2026 06:50:14 +0800 Subject: [PATCH 2/3] fix: default to sonnet model in browse skill to save tokens The browse skill had no model specified in the frontmatter, causing it to use the user's default model (usually Opus) by default. Since browse is orchestrating CLI commands with minimal reasoning required, Sonnet is more cost-effective while maintaining good performance. This addresses issue #8. --- browse/SKILL.md | 1 + 1 file changed, 1 insertion(+) 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, From 0baee4ea1d68d282bb88dda0d72e249ab2dfec99 Mon Sep 17 00:00:00 2001 From: Jah-yee Date: Fri, 13 Mar 2026 18:30:58 +0800 Subject: [PATCH 3/3] Security: Add SSRF and local resource access protection - Add url-validator.ts with validateUrl() function - Blockfile:// URLs by default (opt-in with --allow-file) - Block localhost, 127.0.0.1, ::1 - Block RFC1918 private IP ranges (opt-in with --allow-private) - Block cloud metadata endpoints (169.254.x.x) - Block .internal and .localhost hostnames - Apply validation to goto, newTab, and diff commands Fixes: #17 --- browse/src/browser-manager.ts | 2 + browse/src/meta-commands.ts | 5 ++ browse/src/url-validator.ts | 131 ++++++++++++++++++++++++++++++++++ browse/src/write-commands.ts | 2 + 4 files changed, 140 insertions(+) create mode 100644 browse/src/url-validator.ts 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 652e074..d72345d 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-validator'; import * as Diff from 'diff'; import * as fs from 'fs'; import * as path from 'path'; @@ -172,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/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})`;