Root cause
SSRF & network boundaries
feat(browser): support direct WebSocket CDP URLs for Browserbase
AI-assisted change: Claude
How AI contributed: New attack surfacePublished Apr 17, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary CDP /json/version WebSocket URL could pivot to untrusted second-hop targets. Affected Packages / Versions · Package: openclaw · Ecosystem: npm · Affected versions: < 2026.4.5 · Patched versions: >= 2026.4.5 Impact A browser profile could trust a CDP /json/version response whose webSocketDebuggerUrl pointed at a different host, enabling a second-hop SSRF-style pivot. Technical Details The fix normalizes and re-validates direct CDP WebSocket targets before connecting. Fix The issue was fixed in #60469. The first stable tag containing the fix is v2026.4.5, and [email protected] includes the fix. Fix Commit(s) · bc356cc8c2beaa747c71dd86cceab8f804699665 · PR: #60469 Release Process Note Users should upgrade to openclaw 2026.4.5 or newer. The latest npm release, 2026.4.14, already includes the fix. Credits Thanks to @tdjackey for reporting this issue.
The direct-WebSocket branch added to src/browser/cdp.ts assigned any ws:// or wss:// URL to wsUrl without running the SSRF policy, and isWebSocketUrl accepts a URL on any host, so a CDP /json/version response could point webSocketDebuggerUrl at an untrusted target and pivot the connection there. The fix runs assertCdpEndpointAllowed against the direct and discovered endpoints before connecting.
Root cause
feat(browser): support direct WebSocket CDP URLs for Browserbase
AI-assisted change: Claude
Fix
fix: harden direct CDP websocket validation (#60469) (thanks @eleqtrizit)
Fix by Peter Steinberger · no AI marker found
Case facts
--- a/docs/tools/browser.md+++ b/docs/tools/browser.md@@ -200,9 +200,10 @@ Notes: [Browserbase](https://www.browserbase.com) is a cloud platform for running headless browsers. It provides remote CDP endpoints with built-in CAPTCHA-solving, stealth mode, and residential proxies. You can point an-OpenClaw browser profile at Browserbase's connect endpoint and authenticate-with your API key.+solving, stealth mode, and residential proxies. Unlike Browserless (which+exposes a standard HTTP-based CDP discovery endpoint), Browserbase uses a+direct WebSocket connection — OpenClaw connects to `wss://connect.browserbase.com`+and authenticates via your API key in the query string. Example: @@ -228,6 +229,8 @@ Notes: - [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key** from the [Overview dashboard](https://www.browserbase.com/overview). - Replace `<BROWSERBASE_API_KEY>` with your real Browserbase API key.+- Browserbase auto-creates a browser session on WebSocket connect, so no+ manual session creation step is needed. - The free tier allows one concurrent session and one browser hour per month. See [pricing](https://www.browserbase.com/pricing) for paid plan limits. - See the [Browserbase docs](https://docs.browserbase.com) for full API--- a/src/browser/cdp.helpers.ts+++ b/src/browser/cdp.helpers.ts@@ -7,6 +7,20 @@ import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; export { isLoopbackHost }; +/**+ * Returns true when the URL uses a WebSocket protocol (ws: or wss:).+ * Used to distinguish direct-WebSocket CDP endpoints (e.g. Browserbase)+ * from HTTP(S) endpoints that require /json/version discovery.+ */+export function isWebSocketUrl(url: string): boolean {+ try {+ const parsed = new URL(url);+ return parsed.protocol === "ws:" || parsed.protocol === "wss:";+ } catch {+ return false;+ }+}+ type CdpResponse = { id: number; result?: unknown;--- a/src/browser/cdp.test.ts+++ b/src/browser/cdp.test.ts@@ -95,6 +95,34 @@ describe("cdp", => { expect(created.targetId).toBe("TARGET_123"); }); + it("creates a target via direct WebSocket URL (skips /json/version)", async () => {+ const wsPort = await startWsServerWithMessages((msg, socket) => {+ if (msg.method !== "Target.createTarget") {+ return;+ }+ socket.send(+ JSON.stringify({+ id: msg.id,+ result: { targetId: "TARGET_WS_DIRECT" },+ }),+ );+ });++ const fetchSpy = vi.spyOn(globalThis, "fetch");+ try {+ const created = await createTargetViaCdp({+ cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,+ url: "https://example.com",+ });++ expect(created.targetId).toBe("TARGET_WS_DIRECT");+ // /json/version should NOT have been called — direct WS skips HTTP discovery+ expect(fetchSpy).not.toHaveBeenCalled();+ } finally {+ fetchSpy.mockRestore();+ }+ });+ it("blocks private navigation targets by default", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); try {--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -177,6 +177,7 @@ Docs: https://docs.openclaw.ai - Agents/logging: keep orphaned-user transcript repair warnings focused on interactive runs, and downgrade background-trigger repairs (`heartbeat`, `cron`, `memory`, `overflow`) to debug logs to reduce false-alarm gateway noise. - Gateway/node pairing: require `operator.pairing` for node approvals end-to-end, while still requiring `operator.write` or `operator.admin` when the pending node commands need those higher scopes. (#60461) Thanks @eleqtrizit. - Providers/OpenRouter: gate Anthropic prompt-cache `cache_control` markers to native/default OpenRouter routes and preserve them for native OpenRouter hosts behind custom provider ids. Thanks @vincentkoc.+- Browser/CDP: validate both initial and discovered CDP websocket endpoints before connect so strict SSRF policy blocks cross-host pivots and direct websocket targets. (#60469) Thanks @eleqtrizit. ## 2026.4.1 --- a/extensions/browser/src/browser/cdp.test.ts+++ b/extensions/browser/src/browser/cdp.test.ts@@ -257,6 +257,19 @@ describe("cdp", => { ).rejects.toBeInstanceOf(SsrFBlockedError); }); + it("blocks direct websocket cdp urls outside strict SSRF policy", async () => {+ await expect(+ createTargetViaCdp({+ cdpUrl: "ws://169.254.169.254:9222/devtools/browser/PIVOT",+ url: "https://example.com",+ ssrfPolicy: {+ dangerouslyAllowPrivateNetwork: false,+ allowedHostnames: ["127.0.0.1"],+ },+ }),+ ).rejects.toBeInstanceOf(SsrFBlockedError);+ });+ it("evaluates javascript via CDP", async () => { const wsPort = await startWsServerWithMessages((msg, socket) => { if (msg.method === "Runtime.enable") {--- a/extensions/browser/src/browser/cdp.ts+++ b/extensions/browser/src/browser/cdp.ts@@ -183,6 +183,7 @@ export async function createTargetViaCdp(opts: { let wsUrl: string; if (isWebSocketUrl(opts.cdpUrl)) { // Direct WebSocket URL — skip /json/version discovery.+ await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy); wsUrl = opts.cdpUrl; } else { // Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.AI-assisted change dfbbc9acc22dece75ad2445946997d44e9cdbe4e1ea65b669c95ff1590db48fd · Fix 4cca49ae4e40a260793971759871ace8d43f8b0619e68071b62eca6d426cf027