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
1 change: 1 addition & 0 deletions browse/SKILL.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 2 additions & 0 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,6 +67,7 @@ export class BrowserManager {
this.wirePageEvents(page);

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

Expand Down
24 changes: 24 additions & 0 deletions browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,20 +90,23 @@ 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}`;
}

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}`;
}

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 },
Expand Down Expand Up @@ -153,6 +173,10 @@ export async function handleMetaCommand(
const [url1, url2] = args;
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');

// Validate both URLs
validateUrl(url1);
validateUrl(url2);

// Get text from URL1
const page = bm.getPage();
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
Expand Down
22 changes: 22 additions & 0 deletions browse/src/read-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,6 +119,7 @@ export async function handleReadCommand(
case 'eval': {
const filePath = args[0];
if (!filePath) throw new Error('Usage: browse eval <js-file>');
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);
Expand Down
131 changes: 131 additions & 0 deletions browse/src/url-validator.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
2 changes: 2 additions & 0 deletions browse/src/write-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 { validateUrl } from './url-validator';

export async function handleWriteCommand(
command: string,
Expand All @@ -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})`;
Expand Down