From 954b1fcac48d5b80ff6449c9562a86bf3e7f2667 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 14 Mar 2026 04:02:23 +0800 Subject: [PATCH] Security: Add URL validation to prevent SSRF attacks - Added validateUrl() function to block file://, data:, and other dangerous protocols - Block localhost, 127.0.0.1, and .local domains - Applied validation to goto command and newTab method - Addresses issue #17: SSRF and local resource access via unrestricted URL handling --- browse/src/browser-manager.ts | 26 ++++++++++++++++++++++++++ browse/src/write-commands.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f6726e1..bdd9cbe 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -18,6 +18,28 @@ import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; +/** + * Validate URL to prevent SSRF and local file access attacks + */ +function validateUrl(url: string): void { + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`Invalid URL protocol: ${parsed.protocol}. Only http:// and https:// are allowed.`); + } + const hostname = parsed.hostname.toLowerCase(); + const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1', 'metadata.google.internal', 'metadata.google']; + if (blockedHosts.includes(hostname) || hostname.endsWith('.local')) { + throw new Error(`Access to ${hostname} is not allowed for security reasons.`); + } + } catch (e) { + if (e instanceof TypeError) { + throw new Error(`Invalid URL format. Please provide a valid http:// or https:// URL.`); + } + throw e; + } +} + export class BrowserManager { private browser: Browser | null = null; private context: BrowserContext | null = null; @@ -96,6 +118,10 @@ export class BrowserManager { async newTab(url?: string): Promise { if (!this.context) throw new Error('Browser not launched'); + if (url) { + validateUrl(url); // Security: prevent SSRF attacks + } + const page = await this.context.newPage(); const id = this.nextTabId++; this.pages.set(id, page); diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 08c9425..9de9da6 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -10,6 +10,31 @@ import { findInstalledBrowsers, importCookies } from './cookie-import-browser'; import * as fs from 'fs'; import * as path from 'path'; +/** + * Validate URL to prevent SSRF and local file access attacks + * Only allows http:// and https:// protocols, blocks file://, data:, etc. + */ +function validateUrl(url: string): void { + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`Invalid URL protocol: ${parsed.protocol}. Only http:// and https:// are allowed.`); + } + // Block common localhost variants + const hostname = parsed.hostname.toLowerCase(); + const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1', 'metadata.google.internal', 'metadata.google']; + if (blockedHosts.includes(hostname) || hostname.endsWith('.local')) { + throw new Error(`Access to ${hostname} is not allowed for security reasons.`); + } + } catch (e) { + if (e instanceof TypeError) { + // URL parsing failed - likely a local path like /etc/passwd + throw new Error(`Invalid URL format. Please provide a valid http:// or https:// URL.`); + } + throw e; + } +} + export async function handleWriteCommand( command: string, args: string[], @@ -21,6 +46,7 @@ export async function handleWriteCommand( case 'goto': { const url = args[0]; if (!url) throw new Error('Usage: browse goto '); + validateUrl(url); // Security: prevent SSRF attacks const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); const status = response?.status() || 'unknown'; return `Navigated to ${url} (${status})`;