diff --git a/extensions/browser/src/browser/cdp-reachability-policy.ts b/extensions/browser/src/browser/cdp-reachability-policy.ts index 3ec6a529594..a84388ef46a 100644 --- a/extensions/browser/src/browser/cdp-reachability-policy.ts +++ b/extensions/browser/src/browser/cdp-reachability-policy.ts @@ -17,3 +17,5 @@ export function resolveCdpReachabilityPolicy( } 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.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 05b47698d86..686e26a5730 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -1,4 +1,7 @@ -import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js"; +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"; @@ -87,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,