From 80720b49942e6aa8b1030e71e1dcd3690631a1fd Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:26:32 +0000 Subject: [PATCH] fix(browser): validate cdp websocket pivots --- extensions/browser/src/browser/cdp.test.ts | 17 +++++++++ extensions/browser/src/browser/cdp.ts | 2 ++ extensions/browser/src/browser/chrome.test.ts | 35 +++++++++++++++++++ extensions/browser/src/browser/chrome.ts | 4 ++- 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/extensions/browser/src/browser/cdp.test.ts b/extensions/browser/src/browser/cdp.test.ts index fd421f73cf5..e1d49657054 100644 --- a/extensions/browser/src/browser/cdp.test.ts +++ b/extensions/browser/src/browser/cdp.test.ts @@ -227,6 +227,23 @@ describe("cdp", () => { expect(created.targetId).toBe("TARGET_LOCAL"); }); + it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => { + const httpPort = await startVersionHttpServer({ + webSocketDebuggerUrl: "ws://169.254.169.254:9222/devtools/browser/PIVOT", + }); + + await expect( + createTargetViaCdp({ + cdpUrl: `http://127.0.0.1:${httpPort}`, + 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") { diff --git a/extensions/browser/src/browser/cdp.ts b/extensions/browser/src/browser/cdp.ts index 894f5e28f10..f9de0bd4f8d 100644 --- a/extensions/browser/src/browser/cdp.ts +++ b/extensions/browser/src/browser/cdp.ts @@ -1,6 +1,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { appendCdpPath, + assertCdpEndpointAllowed, fetchJson, isLoopbackHost, isWebSocketUrl, @@ -194,6 +195,7 @@ export async function createTargetViaCdp(opts: { if (!wsUrl) { throw new Error("CDP /json/version missing webSocketDebuggerUrl"); } + await assertCdpEndpointAllowed(wsUrl, opts.ssrfPolicy); } return await withCdpSocket(wsUrl, async (send) => { diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index c3d385d7e17..e78a7ab306b 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -6,11 +6,13 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; +import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { decorateOpenClawProfile, ensureProfileCleanExit, findChromeExecutableMac, findChromeExecutableWindows, + getChromeWebSocketUrl, isChromeCdpReady, isChromeReachable, resolveBrowserExecutableForPlatform, @@ -328,6 +330,39 @@ describe("browser chrome helpers", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => { + const server = createServer((req, res) => { + if (req.url === "/json/version") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + webSocketDebuggerUrl: "ws://169.254.169.254:9222/devtools/browser/pivot", + }), + ); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => resolve()); + server.once("error", reject); + }); + + try { + const addr = server.address() as AddressInfo; + await expect( + getChromeWebSocketUrl(`http://127.0.0.1:${addr.port}`, 50, { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("reports cdpReady only when Browser.getVersion command succeeds", async () => { await withMockChromeCdpServer({ wsPath: "/devtools/browser/health", diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 47aef9a8e3e..1d50cf934b0 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -200,7 +200,9 @@ export async function getChromeWebSocketUrl( if (!wsUrl) { return null; } - return normalizeCdpWsUrl(wsUrl, cdpUrl); + const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, cdpUrl); + await assertCdpEndpointAllowed(normalizedWsUrl, ssrfPolicy); + return normalizedWsUrl; } async function canRunCdpHealthCommand(