diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fca4c1e691..1b44804ca4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Agents/channels: route cross-agent subagent spawns through the target agent's bound channel account while preserving peer and workspace/role-scoped bindings, so child sessions no longer inherit the caller's account in shared rooms, workspaces, or multi-account setups. (#67508) Thanks @lukeboyett and @gumadeiras. - Telegram/callbacks: treat permanent callback edit errors as completed updates so stale command pagination buttons no longer wedge the update watermark and block newer Telegram updates. (#68588) Thanks @Lucenx9. +- Browser/CDP: allow the selected remote CDP profile host for CDP health and control checks without widening browser navigation SSRF policy, so WSL-to-Windows Chrome endpoints no longer appear offline under strict defaults. Fixes #68108. (#68207) Thanks @Mlightsnow. ## 2026.4.18 diff --git a/extensions/browser/src/browser/cdp-reachability-policy.test.ts b/extensions/browser/src/browser/cdp-reachability-policy.test.ts new file mode 100644 index 00000000000..a258b0ec49f --- /dev/null +++ b/extensions/browser/src/browser/cdp-reachability-policy.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; + +function createProfile(overrides: Partial): ResolvedBrowserProfile { + return { + name: "remote", + cdpPort: 9223, + cdpUrl: "http://172.29.128.1:9223", + cdpHost: "172.29.128.1", + cdpIsLoopback: false, + color: "#123456", + driver: "openclaw", + attachOnly: false, + ...overrides, + }; +} + +describe("CDP reachability policy", () => { + it("allows the selected remote profile CDP host without widening browser navigation policy", async () => { + const browserPolicy = {}; + const profile = createProfile({}); + + expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({ + allowedHostnames: ["172.29.128.1"], + }); + expect(browserPolicy).toEqual({}); + await expect( + assertBrowserNavigationAllowed({ + url: "http://172.29.128.1/", + ssrfPolicy: browserPolicy, + }), + ).rejects.toThrow(/private\/internal\/special-use ip address/i); + }); + + it("merges the selected remote profile CDP host with existing CDP policy hostnames", () => { + const profile = createProfile({}); + + expect( + resolveCdpReachabilityPolicy(profile, { + allowedHostnames: ["metadata.internal"], + }), + ).toEqual({ + allowedHostnames: ["metadata.internal", "172.29.128.1"], + }); + }); + + it("keeps local managed loopback CDP control outside browser SSRF policy", () => { + const profile = createProfile({ + cdpUrl: "http://127.0.0.1:18800", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + }); + + expect(resolveCdpReachabilityPolicy(profile, {})).toBeUndefined(); + }); +}); diff --git a/extensions/browser/src/browser/cdp-reachability-policy.ts b/extensions/browser/src/browser/cdp-reachability-policy.ts index 73ebc5d2640..c53bc75ef31 100644 --- a/extensions/browser/src/browser/cdp-reachability-policy.ts +++ b/extensions/browser/src/browser/cdp-reachability-policy.ts @@ -1,7 +1,25 @@ -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { isPrivateNetworkAllowedByPolicy, type SsrFPolicy } from "../infra/net/ssrf.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; +function withCdpHostnameAllowed( + profile: ResolvedBrowserProfile, + ssrfPolicy?: SsrFPolicy, +): SsrFPolicy | undefined { + if (!ssrfPolicy || !profile.cdpHost) { + return ssrfPolicy; + } + if (isPrivateNetworkAllowedByPolicy(ssrfPolicy)) { + return ssrfPolicy; + } + return { + ...ssrfPolicy, + allowedHostnames: Array.from( + new Set([...(ssrfPolicy.allowedHostnames ?? []), profile.cdpHost]), + ), + }; +} + export function resolveCdpReachabilityPolicy( profile: ResolvedBrowserProfile, ssrfPolicy?: SsrFPolicy, @@ -13,7 +31,7 @@ export function resolveCdpReachabilityPolicy( if (!capabilities.isRemote && profile.cdpIsLoopback && profile.driver === "openclaw") { return undefined; } - return ssrfPolicy; + return withCdpHostnameAllowed(profile, ssrfPolicy); } export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy; diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index 29c04f0bfd6..fa17210846e 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -343,7 +343,7 @@ describe("browser config", () => { }); }); - it("auto-allowlists hostnames from user-configured profile cdpUrls", () => { + it("keeps configured profile cdpUrls out of the shared browser SSRF policy", () => { const resolved = resolveBrowserConfig({ profiles: { remote: { @@ -352,41 +352,7 @@ describe("browser config", () => { }, }, }); - expect(resolved.ssrfPolicy).toEqual({ - allowedHostnames: ["172.29.128.1"], - }); - }); - - it("merges configured profile cdpUrl hostnames with existing ssrfPolicy allowedHostnames", () => { - const resolved = resolveBrowserConfig({ - ssrfPolicy: { - allowedHostnames: ["metadata.internal"], - }, - profiles: { - remote: { - color: "#123456", - cdpUrl: "http://172.29.128.1:9223", - }, - }, - }); - expect(resolved.ssrfPolicy?.allowedHostnames?.toSorted()).toEqual( - ["172.29.128.1", "metadata.internal"].toSorted(), - ); - }); - - it("does not duplicate hostnames already in allowedHostnames", () => { - const resolved = resolveBrowserConfig({ - ssrfPolicy: { - allowedHostnames: ["172.29.128.1"], - }, - profiles: { - remote: { - color: "#123456", - cdpUrl: "http://172.29.128.1:9223", - }, - }, - }); - expect(resolved.ssrfPolicy?.allowedHostnames).toEqual(["172.29.128.1"]); + expect(resolved.ssrfPolicy).toEqual({}); }); it("resolves existing-session profiles without cdpPort or cdpUrl", () => { diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 6b0a3de242f..503146dcf4a 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -126,27 +126,11 @@ function resolveCdpPortRangeStart( const normalizeStringList = normalizeOptionalTrimmedStringList; -function mergeAllowedHostnames( - base: string[] | undefined, - extra: readonly string[], -): string[] | undefined { - if (extra.length === 0) { - return base; - } - return Array.from(new Set([...(base ?? []), ...extra])); -} - -function resolveBrowserSsrFPolicy( - cfg: BrowserConfig | undefined, - extraAllowedHostnames: readonly string[] = [], -): SsrFPolicy | undefined { +function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined { const rawPolicy = cfg?.ssrfPolicy as BrowserSsrFPolicyCompat | undefined; const allowPrivateNetwork = rawPolicy?.allowPrivateNetwork; const dangerouslyAllowPrivateNetwork = rawPolicy?.dangerouslyAllowPrivateNetwork; - const allowedHostnames = mergeAllowedHostnames( - normalizeStringList(rawPolicy?.allowedHostnames), - extraAllowedHostnames, - ); + const allowedHostnames = normalizeStringList(rawPolicy?.allowedHostnames); const hostnameAllowlist = normalizeStringList(rawPolicy?.hostnameAllowlist); const hasExplicitPrivateSetting = allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined; @@ -175,30 +159,6 @@ function resolveBrowserSsrFPolicy( }; } -function collectConfiguredCdpHostnames(cfg: BrowserConfig | undefined): string[] { - const hostnames = new Set(); - const addHostnameFromUrl = (rawUrl: string | undefined): void => { - const trimmed = rawUrl?.trim() ?? ""; - if (!trimmed) { - return; - } - try { - const hostname = new URL(trimmed).hostname; - if (hostname) { - hostnames.add(hostname); - } - } catch { - // Ignore unparseable URLs; they will be rejected elsewhere with a proper error. - } - }; - - addHostnameFromUrl(cfg?.cdpUrl); - for (const profile of Object.values(cfg?.profiles ?? {})) { - addHostnameFromUrl(profile?.cdpUrl); - } - return Array.from(hostnames); -} - function ensureDefaultProfile( profiles: Record | undefined, defaultColor: string, @@ -333,7 +293,7 @@ export function resolveBrowserConfig( attachOnly, defaultProfile, profiles, - ssrfPolicy: resolveBrowserSsrFPolicy(cfg, collectConfiguredCdpHostnames(cfg)), + ssrfPolicy: resolveBrowserSsrFPolicy(cfg), extraArgs, }; } 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 d74d9f173df..22a3b69711d 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 @@ -143,14 +143,17 @@ describe("browser server-context loopback direct WebSocket profiles", () => { await openclaw.closeTab("T2"); }); - it("blocks direct WebSocket tab operations when strict SSRF policy rejects the cdpUrl", async () => { + it("blocks direct WebSocket tab operations when strict SSRF hostname allowlist rejects the cdpUrl", async () => { const fetchMock = vi.fn(async () => { throw new Error("unexpected fetch"); }); global.fetch = withFetchPreconnect(fetchMock); const state = makeState("openclaw"); - state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false }; + state.resolved.ssrfPolicy = { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["browserless.example.com"], + }; state.resolved.profiles.openclaw = { cdpUrl: "ws://10.0.0.42:18800/devtools/browser/SESSION?token=abc", color: "#FF4500", diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts index 14777f4719d..4698c9a1c35 100644 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts +++ b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts @@ -149,7 +149,7 @@ describe("browser remote profile tab ops via Playwright", () => { expect(state.profiles.get("remote")?.lastTargetId).toBe("T1"); }); - it("blocks remote Playwright tab operations when strict SSRF policy rejects the cdpUrl", async () => { + it("blocks remote Playwright tab operations when strict SSRF hostname allowlist rejects the cdpUrl", async () => { const listPagesViaPlaywright = vi.fn(async () => [ { targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }, ]); @@ -163,7 +163,10 @@ describe("browser remote profile tab ops via Playwright", () => { } as unknown as Awaited>); const state = deps.makeState("remote"); - state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false }; + state.resolved.ssrfPolicy = { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["browserless.example.com"], + }; state.resolved.profiles.remote = { ...state.resolved.profiles.remote, cdpUrl: "http://10.0.0.42:9222",