From cc54557c646d0dac9966f2f7903448f8f747ecae Mon Sep 17 00:00:00 2001 From: mbelinky Date: Mon, 13 Apr 2026 17:55:56 +0200 Subject: [PATCH] Browser: bypass SSRF for managed loopback CDP probes --- CHANGELOG.md | 2 +- .../src/browser/cdp-reachability-policy.ts | 19 +++++++++++ .../browser/server-context.availability.ts | 8 +++-- ...wser-available.waits-for-cdp-ready.test.ts | 13 +++----- .../server-context.list-profiles.test.ts | 33 +++++++++++++++++++ .../browser/src/browser/server-context.ts | 3 +- 6 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 extensions/browser/src/browser/cdp-reachability-policy.ts create mode 100644 extensions/browser/src/browser/server-context.list-profiles.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f1c01508f..557f1c96da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: patch installed Baileys media encryption writes during OpenClaw postinstall so the default npm/install.sh delivery path waits for encrypted media files to finish flushing before readback, avoiding transient `ENOENT` crashes on image sends. (#65896) Thanks @frankekn. - Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale `dist/entry.js` and current `dist/index.js` paths. (#65984) Thanks @mbelinky. - Heartbeat/Telegram topics: keep isolated heartbeat replies on the bound forum topic when `target=last`, instead of dropping them into the group root chat. (#66035) Thanks @mbelinky. - +- Browser/CDP: let managed local Chrome readiness, status probes, and managed loopback CDP control bypass browser SSRF policy for their own loopback control plane, so OpenClaw no longer misclassifies a healthy child browser as "not reachable after start". (#65695, #66043) Thanks @mbelinky. ## 2026.4.12 ### Changes diff --git a/extensions/browser/src/browser/cdp-reachability-policy.ts b/extensions/browser/src/browser/cdp-reachability-policy.ts new file mode 100644 index 00000000000..3ec6a529594 --- /dev/null +++ b/extensions/browser/src/browser/cdp-reachability-policy.ts @@ -0,0 +1,19 @@ +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; + +export function resolveCdpReachabilityPolicy( + profile: ResolvedBrowserProfile, + ssrfPolicy?: SsrFPolicy, +): SsrFPolicy | undefined { + const capabilities = getBrowserProfileCapabilities(profile); + if ( + capabilities.mode === "local-managed" && + profile.cdpIsLoopback && + !profile.attachOnly && + profile.driver === "openclaw" + ) { + return undefined; + } + return ssrfPolicy; +} diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 976689d5718..26db2187fde 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js"; import { CHROME_MCP_ATTACH_READY_POLL_MS, CHROME_MCP_ATTACH_READY_WINDOW_MS, @@ -67,6 +68,9 @@ export function createProfileAvailability({ remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs, }); + 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 @@ -78,7 +82,7 @@ export function createProfileAvailability({ profile.cdpUrl, httpTimeoutMs, wsTimeoutMs, - state().resolved.ssrfPolicy, + getCdpReachabilityPolicy(), ); }; @@ -87,7 +91,7 @@ export function createProfileAvailability({ return await isReachable(timeoutMs); } const { httpTimeoutMs } = resolveTimeouts(timeoutMs); - return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy); + return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, getCdpReachabilityPolicy()); }; const attachRunning = (running: NonNullable) => { 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 97a41ce64cd..15c1bab4286 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 @@ -21,7 +21,7 @@ function setupEnsureBrowserAvailableHarness() { const ctx = createBrowserRouteContext({ getState: () => state }); const profile = ctx.forProfile("openclaw"); - return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile }; + return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state }; } afterEach(() => { @@ -62,9 +62,10 @@ describe("browser server-context ensureBrowserAvailable", () => { }); it("reuses a pre-existing loopback browser after an initial short probe miss", async () => { - const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } = + const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } = setupEnsureBrowserAvailableHarness(); const isChromeReachable = vi.mocked(chromeModule.isChromeReachable); + state.resolved.ssrfPolicy = {}; isChromeReachable.mockResolvedValueOnce(false).mockResolvedValueOnce(true); isChromeCdpReady.mockResolvedValueOnce(true); @@ -75,17 +76,13 @@ describe("browser server-context ensureBrowserAvailable", () => { 1, "http://127.0.0.1:18800", PROFILE_HTTP_REACHABILITY_TIMEOUT_MS, - { - allowPrivateNetwork: true, - }, + undefined, ); expect(isChromeReachable).toHaveBeenNthCalledWith( 2, "http://127.0.0.1:18800", PROFILE_ATTACH_RETRY_TIMEOUT_MS, - { - allowPrivateNetwork: true, - }, + undefined, ); expect(launchOpenClawChrome).not.toHaveBeenCalled(); expect(stopOpenClawChrome).not.toHaveBeenCalled(); diff --git a/extensions/browser/src/browser/server-context.list-profiles.test.ts b/extensions/browser/src/browser/server-context.list-profiles.test.ts new file mode 100644 index 00000000000..57dc12d1e73 --- /dev/null +++ b/extensions/browser/src/browser/server-context.list-profiles.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import "./server-context.chrome-test-harness.js"; +import * as chromeModule from "./chrome.js"; +import { createBrowserRouteContext } from "./server-context.js"; +import { makeBrowserServerState } from "./server-context.test-harness.js"; + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe("browser server-context listProfiles", () => { + it("bypasses SSRF gating when probing managed loopback profiles", async () => { + const state = makeBrowserServerState({ + resolvedOverrides: { + ssrfPolicy: {}, + }, + }); + const isChromeReachable = vi.mocked(chromeModule.isChromeReachable); + isChromeReachable.mockResolvedValue(true); + + const ctx = createBrowserRouteContext({ getState: () => state }); + const profiles = await ctx.listProfiles(); + + expect(isChromeReachable).toHaveBeenCalledWith("http://127.0.0.1:18800", 200, undefined); + expect(profiles).toEqual([ + expect.objectContaining({ + name: "openclaw", + running: true, + }), + ]); + }); +}); diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts index 0f0c574e290..05b47698d86 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -1,3 +1,4 @@ +import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js"; import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { resolveProfile } from "./config.js"; @@ -189,7 +190,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon const reachable = await isChromeReachable( profile.cdpUrl, 200, - current.resolved.ssrfPolicy, + resolveCdpReachabilityPolicy(profile, current.resolved.ssrfPolicy), ); if (reachable) { running = true;