From 7eecfa411df3d12e6b810e6ca5df47254fc3db3f Mon Sep 17 00:00:00 2001 From: Mason Huang Date: Tue, 14 Apr 2026 16:30:43 +0800 Subject: [PATCH] fix(browser): unblock loopback CDP readiness under strict SSRF defaults (#66354) Merged via squash. Prepared head SHA: d9030ff2f05e4def509128af46171612e450fc43 Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819 --- CHANGELOG.md | 1 + docs/cli/browser.md | 14 ++++ docs/tools/browser.md | 57 ++++++++++++++ .../browser/src/browser/cdp.helpers.test.ts | 77 ++++++++++++++++++- extensions/browser/src/browser/cdp.helpers.ts | 21 ++++- .../chrome.loopback-ssrf.integration.test.ts | 70 +++++++++++++++++ extensions/browser/src/browser/chrome.test.ts | 16 ++-- .../browser/server-context.availability.ts | 1 - 8 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 33bcc3591c6..0da2897b890 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Browser/SSRF: restore hostname navigation under the default browser SSRF policy while keeping explicit strict mode reachable from config, and keep managed loopback CDP `/json/new` fallback requests on the local CDP control policy so browser follow-up fixes stop regressing normal navigation or self-blocking local CDP control. (#66386) Thanks @obviyus. - Browser/SSRF: preserve explicit strict browser navigation mode for legacy `browser.ssrfPolicy.allowPrivateNetwork: false` configs by normalizing the legacy alias to the canonical strict marker instead of silently widening those installs to the default non-strict hostname-navigation path. - Agents/subagents: emit the subagent registry lazy-runtime stub on the stable dist path that both source and bundled runtime imports resolve, so the follow-up dist fix no longer still fails with `ERR_MODULE_NOT_FOUND` at runtime. (#66420) Thanks @obviyus. +- Browser: keep loopback CDP readiness checks reachable under strict SSRF defaults so OpenClaw can reconnect to locally started managed Chrome. (#66354) Thanks @hxy91819. ## 2026.4.14-beta.1 diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 8a6886e5af0..f1d8197d784 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -33,6 +33,20 @@ openclaw browser --browser-profile openclaw open https://example.com openclaw browser --browser-profile openclaw snapshot ``` +## Quick troubleshooting + +If `start` fails with `not reachable after start`, troubleshoot CDP readiness first. If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is healthy and the failure is usually navigation SSRF policy. + +Minimal sequence: + +```bash +openclaw browser --browser-profile openclaw start +openclaw browser --browser-profile openclaw tabs +openclaw browser --browser-profile openclaw open https://example.com +``` + +Detailed guidance: [Browser troubleshooting](/tools/browser#cdp-startup-failure-vs-navigation-ssrf-block) + ## Lifecycle ```bash diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 9510a1f5d3e..c604e96b5d8 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -884,6 +884,63 @@ For Linux-specific issues (especially snap Chromium), see For WSL2 Gateway + Windows Chrome split-host setups, see [WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting). +### CDP startup failure vs navigation SSRF block + +These are different failure classes and they point to different code paths. + +- **CDP startup or readiness failure** means OpenClaw cannot confirm that the browser control plane is healthy. +- **Navigation SSRF block** means the browser control plane is healthy, but a page navigation target is rejected by policy. + +Common examples: + +- CDP startup or readiness failure: + - `Chrome CDP websocket for profile "openclaw" is not reachable after start` + - `Remote CDP for profile "" is not reachable at ` +- Navigation SSRF block: + - `open`, `navigate`, snapshot, or tab-opening flows fail with a browser/network policy error while `start` and `tabs` still work + +Use this minimal sequence to separate the two: + +```bash +openclaw browser --browser-profile openclaw start +openclaw browser --browser-profile openclaw tabs +openclaw browser --browser-profile openclaw open https://example.com +``` + +How to read the results: + +- If `start` fails with `not reachable after start`, troubleshoot CDP readiness first. +- If `start` succeeds but `tabs` fails, the control plane is still unhealthy. Treat this as a CDP reachability problem, not a page-navigation problem. +- If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is up and the failure is in navigation policy or the target page. +- If `start`, `tabs`, and `open` all succeed, the basic managed-browser control path is healthy. + +Important behavior details: + +- Browser config defaults to a fail-closed SSRF policy object even when you do not configure `browser.ssrfPolicy`. +- For the local loopback `openclaw` managed profile, CDP health checks intentionally skip browser SSRF reachability enforcement for OpenClaw's own local control plane. +- Navigation protection is separate. A successful `start` or `tabs` result does not mean a later `open` or `navigate` target is allowed. + +Security guidance: + +- Do **not** relax browser SSRF policy by default. +- Prefer narrow host exceptions such as `hostnameAllowlist` or `allowedHostnames` over broad private-network access. +- Use `dangerouslyAllowPrivateNetwork: true` only in intentionally trusted environments where private-network browser access is required and reviewed. + +Example: navigation blocked, control plane healthy + +- `start` succeeds +- `tabs` succeeds +- `open http://internal.example` fails + +That usually means browser startup is fine and the navigation target needs policy review. + +Example: startup blocked before navigation matters + +- `start` fails with `not reachable after start` +- `tabs` also fails or cannot run + +That points to browser launch or CDP reachability, not a page URL allowlist problem. + ## Agent tools + how control works The agent gets **one tool** for browser automation: diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index bbc42425559..a275fa5b546 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,23 @@ 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("still enforces hostname allowlist for loopback CDP endpoints", async () => { + await expect( + assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.corp.example"], + }), + ).rejects.toThrow("browser endpoint blocked by policy"); + }); + it("releases guarded CDP fetches for bodyless requests", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValueOnce({ @@ -62,4 +79,62 @@ 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); + }); + + it("preserves hostname allowlist while allowing exact 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, + hostnameAllowlist: ["*.corp.example"], + }), + ).resolves.toBeUndefined(); + + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "http://127.0.0.1:9222/json/version", + policy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.corp.example"], + 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..0b777a54029 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -69,8 +69,16 @@ export async function assertCdpEndpointAllowed( throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`); } try { + const policy = isLoopbackHost(parsed.hostname) + ? { + ...ssrfPolicy, + allowedHostnames: Array.from( + new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsed.hostname]), + ), + } + : ssrfPolicy; await resolvePinnedHostnameWithPolicy(parsed.hostname, { - policy: ssrfPolicy, + policy, }); } catch (error) { throw new BrowserCdpEndpointBlockedError({ cause: error }); @@ -263,11 +271,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.loopback-ssrf.integration.test.ts b/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts new file mode 100644 index 00000000000..3ad6966959c --- /dev/null +++ b/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts @@ -0,0 +1,70 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { getChromeWebSocketUrl, isChromeReachable } from "./chrome.js"; + +type RunningServer = { + server: Server; + baseUrl: string; +}; + +const runningServers: Server[] = []; + +async function startLoopbackCdpServer(): Promise { + const server = createServer((req, res) => { + if (req.url !== "/json/version") { + res.statusCode = 404; + res.end("not found"); + return; + } + const address = server.address() as AddressInfo; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + Browser: "Chrome/999.0.0.0", + webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/browser/TEST`, + }), + ); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + runningServers.push(server); + const address = server.address() as AddressInfo; + return { + server, + baseUrl: `http://127.0.0.1:${address.port}`, + }; +} + +afterEach(async () => { + await Promise.all( + runningServers + .splice(0) + .map( + (server) => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ), + ), + ); +}); + +describe("chrome loopback SSRF integration", () => { + it("keeps loopback CDP HTTP reachability working under strict default SSRF policy", async () => { + const { baseUrl } = await startLoopbackCdpServer(); + + await expect(isChromeReachable(baseUrl, 500, {})).resolves.toBe(true); + }); + + it("returns the loopback websocket URL under strict default SSRF policy", async () => { + const { baseUrl } = await startLoopbackCdpServer(); + + await expect(getChromeWebSocketUrl(baseUrl, 500, {})).resolves.toMatch( + /\/devtools\/browser\/TEST$/, + ); + }); +}); 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