Root cause
SSRF & network boundaries
feat(browser): support direct WebSocket CDP URLs for Browserbase
How AI contributed: New attack surfacePublished Apr 17, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary Browser SSRF hostname validation could be bypassed by DNS rebinding. Affected Packages / Versions · Package: openclaw · Ecosystem: npm · Affected versions: < 2026.4.10 · Patched versions: >= 2026.4.10 Impact Browser navigation policy could validate a hostname/IP resolution that differed from the address Chromium ultimately used, allowing DNS rebinding style SSRF pivots. Technical Details The fix tightens strict browser hostname navigation so unallowlisted hostname URLs fail closed under restrictive policy. Fix The issue was fixed in #64367. The first stable tag containing the fix is v2026.4.10, and [email protected] includes the fix. Fix Commit(s) · 121c452d666d4749744dc2089287d0227aae2ed3 · PR: #64367 Release Process Note Users should upgrade to openclaw 2026.4.10 or newer. The latest npm release, 2026.4.14, already includes the fix. Credits Thanks to @zsxsoft, with sponsorship from @KeenSecurityLab and @qclawer for reporting this issue.
src/browser/cdp.ts accepted a direct websocket CDP URL and handed it to WebSocket without the pinned-hostname resolution applied by the HTTP discovery path, so the address the policy approved and the address Chromium dialled could diverge. The fix routes CDP requests through assertCdpEndpointAllowed and makes strict navigation reject non-IP hostnames unless they match an exact or wildcard allowlist.
Root cause
feat(browser): support direct WebSocket CDP URLs for Browserbase
Fix
fix(browser): tighten strict browser hostname navigation (#64367)
Fix by Agustin Rivera · no AI marker found
Case facts
@@ -129,14 +129,16 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | export function parseHttpUrl(raw: string, label: string) { const trimmed = raw.trim(); const parsed = new URL(trimmed);- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {- throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`);+ const allowed = ["http:", "https:", "ws:", "wss:"];+ if (!allowed.includes(parsed.protocol)) {+ throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`); } + const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:"; const port = parsed.port && Number.parseInt(parsed.port, 10) > 0 ? Number.parseInt(parsed.port, 10)- : parsed.protocol === "https:"+ : isSecure ? 443 : 80; @@ -1,8 +1,20 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js";-import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";+import {+ appendCdpPath,+ fetchJson,+ isLoopbackHost,+ isWebSocketUrl,+ withCdpSocket,+} from "./cdp.helpers.js"; import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js"; -export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";+export {+ appendCdpPath,+ fetchJson,+ fetchOk,+ getHeadersWithAuth,+ isWebSocketUrl,+} from "./cdp.helpers.js"; export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string { const ws = new URL(wsUrl);@@ -94,14 +106,21 @@ export async function createTargetViaCdp(opts: { ...withBrowserNavigationPolicy(opts.ssrfPolicy), }); - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(- appendCdpPath(opts.cdpUrl, "/json/version"),- 1500,- );- const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();- const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";- if (!wsUrl) {- throw new Error("CDP /json/version missing webSocketDebuggerUrl");+ let wsUrl: string;+ if (isWebSocketUrl(opts.cdpUrl)) {+ // Direct WebSocket URL (e.g. Browserbase) — skip /json/version discovery.+ wsUrl = opts.cdpUrl;+ } else {+ // Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.+ const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(+ appendCdpPath(opts.cdpUrl, "/json/version"),+ 1500,+ );+ const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();+ wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";+ if (!wsUrl) {+ throw new Error("CDP /json/version missing webSocketDebuggerUrl");+ } } return await withCdpSocket(wsUrl, async (send) => {@@ -17,7 +17,7 @@ import { CHROME_STOP_TIMEOUT_MS, CHROME_WS_READY_TIMEOUT_MS, } from "./cdp-timeouts.js";-import { appendCdpPath, fetchCdpChecked, openCdpWebSocket } from "./cdp.helpers.js";+import { appendCdpPath, fetchCdpChecked, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { type BrowserExecutable,@@ -78,10 +78,29 @@ function cdpUrlForPort(cdpPort: number) { return `http://127.0.0.1:${cdpPort}`; } +async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {+ return new Promise<boolean>((resolve) => {+ const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });+ ws.once("open", () => {+ try {+ ws.close();+ } catch {+ // ignore+ }+ resolve(true);+ });+ ws.once("error", () => resolve(false));+ });+}+ export async function isChromeReachable( cdpUrl: string, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, ): Promise<boolean> {+ if (isWebSocketUrl(cdpUrl)) {+ // Direct WebSocket endpoint (e.g. Browserbase) — probe via WS handshake.+ return await canOpenWebSocket(cdpUrl, timeoutMs);+ } const version = await fetchChromeVersion(cdpUrl, timeoutMs); return Boolean(version); }@@ -117,6 +136,10 @@ export async function getChromeWebSocketUrl( cdpUrl: string, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, ): Promise<string | null> {+ if (isWebSocketUrl(cdpUrl)) {+ // Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.+ return cdpUrl;+ } const version = await fetchChromeVersion(cdpUrl, timeoutMs); const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); if (!wsUrl) {@@ -1,3 +1,8 @@+import { isIP } from "node:net";+import {+ matchesHostnameAllowlist,+ normalizeHostname,+} from "openclaw/plugin-sdk/browser-security-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; import {@@ -41,6 +46,24 @@ export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFP return !isPrivateNetworkAllowedByPolicy(ssrfPolicy); } +function isIpLiteralHostname(hostname: string): boolean {+ return isIP(normalizeHostname(hostname)) !== 0;+}++function isExplicitlyAllowedBrowserHostname(hostname: string, ssrfPolicy?: SsrFPolicy): boolean {+ const normalizedHostname = normalizeHostname(hostname);+ const exactMatches = ssrfPolicy?.allowedHostnames ?? [];+ if (exactMatches.some((value) => normalizeHostname(value) === normalizedHostname)) {+ return true;+ }+ const hostnameAllowlist = (ssrfPolicy?.hostnameAllowlist ?? [])+ .map((pattern) => normalizeHostname(pattern))+ .filter(Boolean);+ return hostnameAllowlist.length > 0+ ? matchesHostnameAllowlist(normalizedHostname, hostnameAllowlist)+ : false;+}+ export async function assertBrowserNavigationAllowed( opts: { url: string;@@ -78,6 +101,21 @@ export async function assertBrowserNavigationAllowed( ); } + // Browser navigations happen in Chromium's network stack, not Node's. In+ // strict mode, a hostname-based URL would be resolved twice by different+ // resolvers, so Node-side pinning cannot guarantee the browser connects to+ // the same address that passed policy checks.+ if (+ opts.ssrfPolicy &&+ !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy) &&+ !isIpLiteralHostname(parsed.hostname) &&+ !isExplicitlyAllowedBrowserHostname(parsed.hostname, opts.ssrfPolicy)+ ) {+ throw new InvalidBrowserNavigationUrlError(+ "Navigation blocked: strict browser SSRF policy requires an IP-literal URL because browser DNS rebinding protections are unavailable for hostname-based navigation",+ );+ }+ await resolvePinnedHostnameWithPolicy(parsed.hostname, { lookupFn: opts.lookupFn, policy: opts.ssrfPolicy,@@ -87,7 +125,8 @@ export async function assertBrowserNavigationAllowed( /** * Best-effort post-navigation guard for final page URLs. * Only validates network URLs (http/https) and about:blank to avoid false- * positives on browser-internal error pages (e.g. chrome-error://).+ * positives on browser-internal error pages (e.g. chrome-error://). In strict+ * mode this intentionally re-applies the hostname gate after redirects. */ export async function assertBrowserNavigationResultAllowed( opts: {@@ -116,6 +116,85 @@ describe("browser navigation guard", () => { expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true }); }); + it("blocks hostname navigation when strict SSRF policy is explicitly configured", async () => {+ const lookupFn = createLookupFn("93.184.216.34");+ await expect(+ assertBrowserNavigationAllowed({+ url: "https://example.com",+ lookupFn,+ ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },+ }),+ ).rejects.toThrow(/dns rebinding protections are unavailable/i);+ expect(lookupFn).not.toHaveBeenCalled();+ });++ it("allows explicitly allowed hostnames in strict mode", async () => {+ const lookupFn = createLookupFn("93.184.216.34");+ await expect(+ assertBrowserNavigationAllowed({+ url: "https://agent.internal",+ lookupFn,+ ssrfPolicy: {+ dangerouslyAllowPrivateNetwork: false,+ allowedHostnames: ["agent.internal"],+ },+ }),+ ).resolves.toBeUndefined();+ });++ it("allows wildcard-allowlisted hostnames in strict mode", async () => {+ const lookupFn = createLookupFn("93.184.216.34");+ await expect(+ assertBrowserNavigationAllowed({+ url: "https://sub.example.com",+ lookupFn,+ ssrfPolicy: {+ dangerouslyAllowPrivateNetwork: false,+ hostnameAllowlist: ["*.example.com"],+ },+ }),+ ).resolves.toBeUndefined();+ });++ it("does not treat the bare suffix as matching a wildcard allowlist entry", async () => {+ const lookupFn = createLookupFn("93.184.216.34");+ await expect(+ assertBrowserNavigationAllowed({+ url: "https://example.com",+ lookupFn,+ ssrfPolicy: {+ dangerouslyAllowPrivateNetwork: false,+ hostnameAllowlist: ["*.example.com"],+ },+ }),+ ).rejects.toThrow(/dns rebinding protections are unavailable/i);+ expect(lookupFn).not.toHaveBeenCalled();+ });++ it("does not match sibling domains against wildcard allowlist entries", async () => {+ const lookupFn = createLookupFn("93.184.216.34");+ await expect(+ assertBrowserNavigationAllowed({+ url: "https://evil-example.com",+ lookupFn,+ ssrfPolicy: {+ dangerouslyAllowPrivateNetwork: false,+ hostnameAllowlist: ["*.example.com"],+ },+ }),+ ).rejects.toThrow(/dns rebinding protections are unavailable/i);+ expect(lookupFn).not.toHaveBeenCalled();+ });++ it("treats bracketed IPv6 URL hostnames as IP literals in strict mode", async () => {+ await expect(+ assertBrowserNavigationAllowed({+ url: "https://[2606:4700:4700::1111]/",+ ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },+ }),+ ).resolves.toBeUndefined();+ });+ it("blocks strict policy navigation when env proxy is configured", async () => { vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); const lookupFn = createLookupFn("93.184.216.34");@@ -165,6 +244,15 @@ describe("browser navigation guard", () => { ).resolves.toBeUndefined(); }); @@ -2,16 +2,11 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import WebSocket from "ws"; import { isLoopbackHost } from "../gateway/net.js";-import {- SsrFBlockedError,- type SsrFPolicy,- resolvePinnedHostnameWithPolicy,-} from "../infra/net/ssrf.js";+import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js"; import { rawDataToString } from "../infra/ws.js"; import { redactSensitiveText } from "../logging/redact.js"; import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";-import { BrowserCdpEndpointBlockedError } from "./errors.js"; import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js"; export { isLoopbackHost };@@ -68,19 +63,9 @@ export async function assertCdpEndpointAllowed( if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) { throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`); }- try {- await resolvePinnedHostnameWithPolicy(parsed.hostname, {- policy: ssrfPolicy,- });- } catch (err) {- // Rethrow SSRF policy failures against the CDP endpoint itself as a- // browser-endpoint-scoped error so the route mapping does not confuse- // them with navigation-target policy blocks.- if (err instanceof SsrFBlockedError) {- throw new BrowserCdpEndpointBlockedError({ cause: err });- }- throw err;- }+ await resolvePinnedHostnameWithPolicy(parsed.hostname, {+ policy: ssrfPolicy,+ }); } export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined {@@ -168,6 +153,11 @@ export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string { } } +type CdpFetchResult = {+ response: Response;+ release: () => Promise<void>;+};+ function createCdpSender(ws: WebSocket) { let nextId = 1; const pending = new Map<number, Pending>();@@ -233,72 +223,72 @@ export async function fetchJson<T>( url: string, timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, init?: RequestInit,+ ssrfPolicy?: SsrFPolicy, ): Promise<T> {- const res = await fetchCdpChecked(url, timeoutMs, init);- return (await res.json()) as T;+ const { response, release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);+ try {+ return (await response.json()) as T;+ } finally {+ await release();+ } } export async function fetchCdpChecked( url: string, timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, init?: RequestInit,-): Promise<Response> {+ ssrfPolicy?: SsrFPolicy,+): Promise<CdpFetchResult> { const ctrl = new AbortController(); const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);- let release: (() => Promise<void>) | undefined;+ let guardedRelease: (() => Promise<void>) | undefined;+ let released = false;+ const release = async () => {+ if (released) {+ return;+ }+ released = true;+ clearTimeout(t);+ await guardedRelease?.();+ }; try {AI-assisted change 9cc43d9c875845efe5b0f7a8d76f196d0185bba1200ea661193e480ad5d26383 · Fix c00cdb6a505ba792176c05df321defaeaa10790b2e3c986023494f3a634fa6c8