From 220bd95eff6838234e8b4b711f86d4565e16e401 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 11:31:13 +0100 Subject: [PATCH] fix(browser): block non-network navigation schemes --- CHANGELOG.md | 1 + src/browser/navigation-guard.test.ts | 34 +++++++++++++++++++++++++++- src/browser/navigation-guard.ts | 8 ++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97775abc61b..d5f68b1a42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) thanks @coygeek. - Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow. - Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow. +- Security/Browser: block non-network browser navigation protocols (including `file:`, `data:`, and `javascript:`) while preserving `about:blank`, preventing local file reads via browser tool navigation. This ships in the next npm release. Thanks @q1uf3ng for reporting. - Security/Gateway/Hooks: block `__proto__`, `constructor`, and `prototype` traversal in webhook template path resolution to prevent prototype-chain payload data leakage in `messageTemplate` rendering. (#22213) Thanks @SleuthCo. - Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc. - Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc. diff --git a/src/browser/navigation-guard.test.ts b/src/browser/navigation-guard.test.ts index efa07be6390..3a096aac8d9 100644 --- a/src/browser/navigation-guard.test.ts +++ b/src/browser/navigation-guard.test.ts @@ -19,7 +19,7 @@ describe("browser navigation guard", () => { ).rejects.toBeInstanceOf(SsrFBlockedError); }); - it("allows non-network schemes", async () => { + it("allows about:blank", async () => { await expect( assertBrowserNavigationAllowed({ url: "about:blank", @@ -27,6 +27,38 @@ describe("browser navigation guard", () => { ).resolves.toBeUndefined(); }); + it("blocks file URLs", async () => { + await expect( + assertBrowserNavigationAllowed({ + url: "file:///etc/passwd", + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + }); + + it("blocks data URLs", async () => { + await expect( + assertBrowserNavigationAllowed({ + url: "data:text/html,

owned

", + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + }); + + it("blocks javascript URLs", async () => { + await expect( + assertBrowserNavigationAllowed({ + url: "javascript:alert(1)", + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + }); + + it("blocks non-blank about URLs", async () => { + await expect( + assertBrowserNavigationAllowed({ + url: "about:srcdoc", + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + }); + it("allows blocked hostnames when explicitly allowed", async () => { const lookupFn = createLookupFn("127.0.0.1"); await expect( diff --git a/src/browser/navigation-guard.ts b/src/browser/navigation-guard.ts index f5c4048476d..5567642e3b0 100644 --- a/src/browser/navigation-guard.ts +++ b/src/browser/navigation-guard.ts @@ -5,6 +5,7 @@ import { } from "../infra/net/ssrf.js"; const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]); +const SAFE_NON_NETWORK_URLS = new Set(["about:blank"]); export class InvalidBrowserNavigationUrlError extends Error { constructor(message: string) { @@ -42,7 +43,12 @@ export async function assertBrowserNavigationAllowed( } if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) { - return; + if (SAFE_NON_NETWORK_URLS.has(parsed.href)) { + return; + } + throw new InvalidBrowserNavigationUrlError( + `Navigation blocked: unsupported protocol "${parsed.protocol}"`, + ); } await resolvePinnedHostnameWithPolicy(parsed.hostname, {