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..a84388ef46a --- /dev/null +++ b/extensions/browser/src/browser/cdp-reachability-policy.ts @@ -0,0 +1,21 @@ +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; +} + +export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy; diff --git a/extensions/browser/src/browser/navigation-guard.ts b/extensions/browser/src/browser/navigation-guard.ts index 552a1dcdbc7..7005b1c18bd 100644 --- a/extensions/browser/src/browser/navigation-guard.ts +++ b/extensions/browser/src/browser/navigation-guard.ts @@ -46,6 +46,21 @@ export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFP return !isPrivateNetworkAllowedByPolicy(ssrfPolicy); } +export function requiresInspectableBrowserNavigationRedirectsForUrl( + url: string, + ssrfPolicy?: SsrFPolicy, +): boolean { + if (!requiresInspectableBrowserNavigationRedirects(ssrfPolicy)) { + return false; + } + try { + const parsed = new URL(url); + return NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol); + } catch { + return false; + } +} + function isIpLiteralHostname(hostname: string): boolean { return isIP(normalizeHostname(hostname)) !== 0; } 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.loopback-direct-ws.test.ts b/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts index 138c50ef464..d74d9f173df 100644 --- a/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts +++ b/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts @@ -11,7 +11,7 @@ afterEach(() => { }); describe("browser server-context loopback direct WebSocket profiles", () => { - it("uses an HTTP /json/list base when opening tabs", async () => { + it("uses an HTTP /json/list base when opening about:blank under strict SSRF", async () => { const createTargetViaCdp = vi .spyOn(cdpModule, "createTargetViaCdp") .mockResolvedValue({ targetId: "CREATED" }); @@ -25,7 +25,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => { { id: "CREATED", title: "New Tab", - url: "http://127.0.0.1:8080", + url: "about:blank", webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED", type: "page", }, @@ -35,6 +35,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => { global.fetch = withFetchPreconnect(fetchMock); const state = makeState("openclaw"); + state.resolved.ssrfPolicy = {}; state.resolved.profiles.openclaw = { cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", color: "#FF4500", @@ -42,16 +43,16 @@ describe("browser server-context loopback direct WebSocket profiles", () => { const ctx = createBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); - const opened = await openclaw.openTab("http://127.0.0.1:8080"); + const opened = await openclaw.openTab("about:blank"); expect(opened.targetId).toBe("CREATED"); expect(createTargetViaCdp).toHaveBeenCalledWith({ cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", - url: "http://127.0.0.1:8080", - ssrfPolicy: { allowPrivateNetwork: true }, + url: "about:blank", + ssrfPolicy: undefined, }); }); - it("uses an HTTP /json base for focus and close", async () => { + it("uses an HTTP /json base for focus and close under strict SSRF", async () => { const fetchMock = vi.fn(async (url: unknown) => { const u = String(url); if (u === "http://127.0.0.1:18800/json/list?token=abc") { @@ -79,6 +80,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => { global.fetch = withFetchPreconnect(fetchMock); const state = makeState("openclaw"); + state.resolved.ssrfPolicy = {}; state.resolved.profiles.openclaw = { cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", color: "#FF4500", diff --git a/extensions/browser/src/browser/server-context.selection.ts b/extensions/browser/src/browser/server-context.selection.ts index 6d8535a023e..5a78e66bcbc 100644 --- a/extensions/browser/src/browser/server-context.selection.ts +++ b/extensions/browser/src/browser/server-context.selection.ts @@ -14,7 +14,7 @@ import { resolveTargetIdFromTabs } from "./target-id.js"; type SelectionDeps = { profile: ResolvedBrowserProfile; getProfileState: () => ProfileRuntimeState; - getSsrFPolicy: () => SsrFPolicy | undefined; + getCdpControlPolicy: () => SsrFPolicy | undefined; ensureBrowserAvailable: () => Promise; listTabs: () => Promise; openTab: (url: string) => Promise; @@ -29,7 +29,7 @@ type SelectionOps = { export function createProfileSelectionOps({ profile, getProfileState, - getSsrFPolicy, + getCdpControlPolicy, ensureBrowserAvailable, listTabs, openTab, @@ -112,7 +112,7 @@ export function createProfileSelectionOps({ await focusPageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, targetId: resolvedTargetId, - ssrfPolicy: getSsrFPolicy(), + ssrfPolicy: getCdpControlPolicy(), }); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; @@ -124,7 +124,7 @@ export function createProfileSelectionOps({ appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`), undefined, undefined, - getSsrFPolicy(), + getCdpControlPolicy(), ); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; @@ -147,7 +147,7 @@ export function createProfileSelectionOps({ await closePageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, targetId: resolvedTargetId, - ssrfPolicy: getSsrFPolicy(), + ssrfPolicy: getCdpControlPolicy(), }); return; } @@ -157,7 +157,7 @@ export function createProfileSelectionOps({ appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`), undefined, undefined, - getSsrFPolicy(), + getCdpControlPolicy(), ); }; diff --git a/extensions/browser/src/browser/server-context.tab-ops.ts b/extensions/browser/src/browser/server-context.tab-ops.ts index a43930f4b97..9344f4af6cd 100644 --- a/extensions/browser/src/browser/server-context.tab-ops.ts +++ b/extensions/browser/src/browser/server-context.tab-ops.ts @@ -1,3 +1,4 @@ +import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js"; import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; import { assertCdpEndpointAllowed, @@ -12,7 +13,7 @@ import { assertBrowserNavigationAllowed, assertBrowserNavigationResultAllowed, InvalidBrowserNavigationUrlError, - requiresInspectableBrowserNavigationRedirects, + requiresInspectableBrowserNavigationRedirectsForUrl, withBrowserNavigationPolicy, } from "./navigation-guard.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; @@ -69,7 +70,7 @@ export function createProfileTabOps({ }: TabOpsDeps): ProfileTabOps { const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); const capabilities = getBrowserProfileCapabilities(profile); - const getSsrFPolicy = () => state().resolved.ssrfPolicy; + const getCdpControlPolicy = () => resolveCdpControlPolicy(profile, state().resolved.ssrfPolicy); const listTabs = async (): Promise => { if (capabilities.usesChromeMcp) { @@ -80,7 +81,7 @@ export function createProfileTabOps({ const mod = await getPwAiModule({ mode: "strict" }); const listPagesViaPlaywright = (mod as Partial | null)?.listPagesViaPlaywright; if (typeof listPagesViaPlaywright === "function") { - const ssrfPolicy = getSsrFPolicy(); + const ssrfPolicy = getCdpControlPolicy(); await assertCdpEndpointAllowed(profile.cdpUrl, ssrfPolicy); const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl, ssrfPolicy }); return pages.map((p) => ({ @@ -100,7 +101,7 @@ export function createProfileTabOps({ webSocketDebuggerUrl?: string; type?: string; }> - >(appendCdpPath(cdpHttpBase, "/json/list"), undefined, undefined, getSsrFPolicy()); + >(appendCdpPath(cdpHttpBase, "/json/list"), undefined, undefined, getCdpControlPolicy()); return raw .map((t) => ({ targetId: t.id ?? "", @@ -136,7 +137,7 @@ export function createProfileTabOps({ appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`), undefined, undefined, - getSsrFPolicy(), + getCdpControlPolicy(), ).catch(() => { // best-effort cleanup only }); @@ -182,7 +183,7 @@ export function createProfileTabOps({ } } - if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) { + if (requiresInspectableBrowserNavigationRedirectsForUrl(url, state().resolved.ssrfPolicy)) { throw new InvalidBrowserNavigationUrlError( "Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection", ); @@ -191,7 +192,7 @@ export function createProfileTabOps({ const createdViaCdp = await createTargetViaCdp({ cdpUrl: profile.cdpUrl, url, - ...ssrfPolicyOpts, + ssrfPolicy: getCdpControlPolicy(), }) .then((r) => r.targetId) .catch(() => null); diff --git a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts index 12f42c68f22..f6bd2c5d704 100644 --- a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts +++ b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts @@ -125,7 +125,51 @@ describe("browser server-context tab selection state", () => { expect(createTargetViaCdp).toHaveBeenCalledWith({ cdpUrl: "http://127.0.0.1:18800", url: "http://127.0.0.1:8080", - ssrfPolicy: { allowPrivateNetwork: true }, + ssrfPolicy: undefined, + }); + }); + + it("can bootstrap a managed loopback tab under strict SSRF because CDP control stays local", async () => { + const createTargetViaCdp = vi + .spyOn(cdpModule, "createTargetViaCdp") + .mockResolvedValue({ targetId: "CREATED" }); + + let listCount = 0; + const fetchMock = vi.fn(async (url: unknown) => { + const u = String(url); + if (!u.includes("/json/list")) { + throw new Error(`unexpected fetch: ${u}`); + } + listCount += 1; + return { + ok: true, + json: async () => + listCount === 1 + ? [] + : [ + { + id: "CREATED", + title: "New Tab", + url: "about:blank", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED", + type: "page", + }, + ], + } as unknown as Response; + }); + + global.fetch = withFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + state.resolved.ssrfPolicy = {}; + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + const selected = await openclaw.ensureTabAvailable(); + expect(selected.targetId).toBe("CREATED"); + expect(createTargetViaCdp).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:18800", + url: "about:blank", + ssrfPolicy: undefined, }); }); diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts index 0f0c574e290..686e26a5730 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -1,3 +1,7 @@ +import { + resolveCdpControlPolicy, + resolveCdpReachabilityPolicy, +} from "./cdp-reachability-policy.js"; import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { resolveProfile } from "./config.js"; @@ -86,7 +90,7 @@ function createProfileContext( const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({ profile, getProfileState, - getSsrFPolicy: () => state().resolved.ssrfPolicy, + getCdpControlPolicy: () => resolveCdpControlPolicy(profile, state().resolved.ssrfPolicy), ensureBrowserAvailable, listTabs, openTab, @@ -189,7 +193,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;