From f6971d6f2888c3aaee6085fef32c2787fbd22381 Mon Sep 17 00:00:00 2001 From: Mason Huang Date: Mon, 13 Apr 2026 23:54:50 +0800 Subject: [PATCH] fix(browser): unblock loopback CDP readiness --- .../browser/src/browser/cdp.helpers.test.ts | 38 ++++++++++++++++++- extensions/browser/src/browser/cdp.helpers.ts | 16 +++++++- extensions/browser/src/browser/chrome.test.ts | 16 +++++--- .../browser/server-context.availability.ts | 1 - ...wser-available.waits-for-cdp-ready.test.ts | 8 +++- 5 files changed, 69 insertions(+), 10 deletions(-) diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index bbc42425559..692ae5d73aa 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -10,7 +10,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { }; }); -import { fetchJson, fetchOk } from "./cdp.helpers.js"; +import { assertCdpEndpointAllowed, fetchJson, fetchOk } from "./cdp.helpers.js"; describe("cdp helpers", () => { afterEach(() => { @@ -43,6 +43,14 @@ describe("cdp helpers", () => { expect(release).toHaveBeenCalledTimes(1); }); + it("allows loopback CDP endpoints in strict SSRF mode", async () => { + await expect( + assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", { + dangerouslyAllowPrivateNetwork: false, + }), + ).resolves.toBeUndefined(); + }); + it("releases guarded CDP fetches for bodyless requests", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValueOnce({ @@ -62,4 +70,32 @@ describe("cdp helpers", () => { expect(release).toHaveBeenCalledTimes(1); }); + + it("uses an exact loopback allowlist for guarded loopback CDP fetches", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { + ok: true, + status: 200, + }, + release, + }); + + await expect( + fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, { + dangerouslyAllowPrivateNetwork: false, + }), + ).resolves.toBeUndefined(); + + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "http://127.0.0.1:9222/json/version", + policy: { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }, + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index da758d2488a..1b04b2c7cc0 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -68,6 +68,11 @@ export async function assertCdpEndpointAllowed( if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) { throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`); } + // Loopback CDP endpoints are internal browser-control hops, not + // agent-controlled navigation targets. + if (isLoopbackHost(parsed.hostname)) { + return; + } try { await resolvePinnedHostnameWithPolicy(parsed.hostname, { policy: ssrfPolicy, @@ -263,11 +268,20 @@ export async function fetchCdpChecked( try { const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); const res = await withNoProxyForCdpUrl(url, async () => { + const parsedUrl = new URL(url); + const policy = isLoopbackHost(parsedUrl.hostname) + ? { + ...ssrfPolicy, + allowedHostnames: Array.from( + new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsedUrl.hostname]), + ), + } + : (ssrfPolicy ?? { allowPrivateNetwork: true }); const guarded = await fetchWithSsrFGuard({ url, init: { ...init, headers }, signal: ctrl.signal, - policy: ssrfPolicy ?? { allowPrivateNetwork: true }, + policy, auditContext: "browser-cdp", }); guardedRelease = guarded.release; diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index cc0b3f49d93..fb7b137d503 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -312,22 +312,28 @@ describe("browser chrome helpers", () => { await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false); }); - it("blocks private CDP probes when strict SSRF policy is enabled", async () => { - const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called")); + it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), + } as unknown as Response) + .mockRejectedValue(new Error("should not be called")); vi.stubGlobal("fetch", fetchSpy); await expect( isChromeReachable("http://127.0.0.1:12345", 50, { dangerouslyAllowPrivateNetwork: false, }), - ).resolves.toBe(false); + ).resolves.toBe(true); await expect( - isChromeReachable("ws://127.0.0.1:19999", 50, { + isChromeReachable("http://169.254.169.254:12345", 50, { dangerouslyAllowPrivateNetwork: false, }), ).resolves.toBe(false); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); }); it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => { diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 5f56db7fab1..531e625a630 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -71,7 +71,6 @@ export function createProfileAvailability({ const getCdpReachabilityPolicy = () => resolveCdpReachabilityPolicy(profile, state().resolved.ssrfPolicy); - const isReachable = async (timeoutMs?: number) => { if (capabilities.usesChromeMcp) { // listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index 39f3e5770ab..fa5eecfb0a5 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -76,13 +76,17 @@ describe("browser server-context ensureBrowserAvailable", () => { 1, "http://127.0.0.1:18800", PROFILE_HTTP_REACHABILITY_TIMEOUT_MS, - undefined, + { + allowPrivateNetwork: true, + }, ); expect(isChromeReachable).toHaveBeenNthCalledWith( 2, "http://127.0.0.1:18800", PROFILE_ATTACH_RETRY_TIMEOUT_MS, - undefined, + { + allowPrivateNetwork: true, + }, ); expect(launchOpenClawChrome).not.toHaveBeenCalled(); expect(stopOpenClawChrome).not.toHaveBeenCalled();