diff --git a/extensions/browser/src/browser/browser-proxy-mode.test.ts b/extensions/browser/src/browser/browser-proxy-mode.test.ts index 33c408bd67d4..ecc347f63bf9 100644 --- a/extensions/browser/src/browser/browser-proxy-mode.test.ts +++ b/extensions/browser/src/browser/browser-proxy-mode.test.ts @@ -35,19 +35,25 @@ describe("browser proxy mode", () => { expect( resolveBrowserNavigationProxyMode({ resolved, - profile: { driver: "openclaw", cdpIsLoopback: true }, + profile: { driver: "openclaw", cdpIsLoopback: true, attachOnly: false }, }), ).toBe("explicit-browser-proxy"); expect( resolveBrowserNavigationProxyMode({ resolved, - profile: { driver: "existing-session", cdpIsLoopback: true }, + profile: { driver: "existing-session", cdpIsLoopback: true, attachOnly: true }, }), ).toBe("direct"); expect( resolveBrowserNavigationProxyMode({ resolved, - profile: { driver: "openclaw", cdpIsLoopback: false }, + profile: { driver: "openclaw", cdpIsLoopback: false, attachOnly: true }, + }), + ).toBe("direct"); + expect( + resolveBrowserNavigationProxyMode({ + resolved, + profile: { driver: "openclaw", cdpIsLoopback: true, attachOnly: true }, }), ).toBe("direct"); }); diff --git a/extensions/browser/src/browser/browser-proxy-mode.ts b/extensions/browser/src/browser/browser-proxy-mode.ts index 0eab551af086..9551b6403efe 100644 --- a/extensions/browser/src/browser/browser-proxy-mode.ts +++ b/extensions/browser/src/browser/browser-proxy-mode.ts @@ -52,11 +52,12 @@ export function omitChromeProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { /** Resolve the navigation proxy mode used by SSRF/navigation guards. */ export function resolveBrowserNavigationProxyMode(params: { resolved: Pick; - profile: Pick; + profile: Pick; }): BrowserNavigationProxyMode { if ( params.profile.driver === "openclaw" && params.profile.cdpIsLoopback && + !params.profile.attachOnly && hasExplicitChromeProxyRoutingArg(params.resolved.extraArgs) ) { return "explicit-browser-proxy"; diff --git a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts index 2ecd3b6870d0..f78b06959612 100644 --- a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts +++ b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts @@ -5,7 +5,7 @@ import "../server-context.chrome-test-harness.js"; import "../../test-support/browser-security.mock.js"; import * as chromeModule from "../chrome.js"; import { createBrowserRouteContext } from "../server-context.js"; -import { makeBrowserServerState } from "../server-context.test-harness.js"; +import { makeBrowserProfile, makeBrowserServerState } from "../server-context.test-harness.js"; import { registerBrowserTabRoutes } from "./tabs.js"; import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js"; @@ -14,20 +14,22 @@ afterEach(() => { vi.restoreAllMocks(); }); +function makeLoopbackProfile(attachOnly: boolean) { + return makeBrowserProfile({ + name: "manual-cdp", + cdpUrl: "http://127.0.0.1:9222", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + cdpPort: 9222, + color: "#00AA00", + attachOnly, + }); +} + describe("browser tab routes attachOnly loopback profiles", () => { it("lists tabs for manual loopback CDP profiles under strict SSRF", async () => { const state = makeBrowserServerState({ - profile: { - name: "manual-cdp", - cdpUrl: "http://127.0.0.1:9222", - cdpHost: "127.0.0.1", - cdpIsLoopback: true, - cdpPort: 9222, - color: "#00AA00", - driver: "openclaw", - headless: false, - attachOnly: true, - }, + profile: makeLoopbackProfile(true), resolvedOverrides: { defaultProfile: "manual-cdp", ssrfPolicy: {}, @@ -85,4 +87,76 @@ describe("browser tab routes attachOnly loopback profiles", () => { ], }); }); + + it.each([ + { attachOnly: false, allowPrivateNetwork: false, expectedUrl: "" }, + { + attachOnly: false, + allowPrivateNetwork: true, + expectedUrl: "http://93.184.216.34/proxy-routed", + }, + { + attachOnly: true, + allowPrivateNetwork: false, + expectedUrl: "http://93.184.216.34/proxy-routed", + }, + ])( + "applies managed browser proxy policy to tab list URLs (attachOnly=$attachOnly, allowPrivateNetwork=$allowPrivateNetwork)", + async ({ attachOnly, allowPrivateNetwork, expectedUrl }) => { + const state = makeBrowserServerState({ + profile: makeLoopbackProfile(attachOnly), + resolvedOverrides: { + defaultProfile: "manual-cdp", + extraArgs: ["--proxy-server=http://proxy.example.test:8080"], + ssrfPolicy: { dangerouslyAllowPrivateNetwork: allowPrivateNetwork }, + }, + }); + + const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady); + isChromeCdpReady.mockResolvedValue(true); + + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify([ + { + id: "PAGE-1", + title: "Proxy routed", + url: "http://93.184.216.34/proxy-routed", + webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1", + type: "page", + }, + ]), + { headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const ctx = createBrowserRouteContext({ getState: () => state }); + const { app, getHandlers, postHandlers } = createBrowserRouteApp(); + registerBrowserTabRoutes(app, ctx as never); + const getTabs = getHandlers.get("/tabs"); + const postTabsAction = postHandlers.get("/tabs/action"); + expect(getTabs).toBeTypeOf("function"); + expect(postTabsAction).toBeTypeOf("function"); + + const getResponse = createBrowserRouteResponse(); + await getTabs?.({ params: {}, query: { profile: "manual-cdp" }, body: {} }, getResponse.res); + + const actionResponse = createBrowserRouteResponse(); + await postTabsAction?.( + { params: {}, query: { profile: "manual-cdp" }, body: { action: "list" } }, + actionResponse.res, + ); + + expect(getResponse.statusCode).toBe(200); + expect(actionResponse.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + for (const response of [getResponse, actionResponse]) { + expect(response.body).toMatchObject({ + tabs: [{ targetId: "PAGE-1", title: "Proxy routed", url: expectedUrl }], + }); + } + }, + ); }); diff --git a/extensions/browser/src/browser/routes/tabs.test.ts b/extensions/browser/src/browser/routes/tabs.test.ts index 3c3960615c94..ca53ba5fe393 100644 --- a/extensions/browser/src/browser/routes/tabs.test.ts +++ b/extensions/browser/src/browser/routes/tabs.test.ts @@ -122,6 +122,7 @@ function createRouteContext( state: () => ({ resolved: { actionTimeoutMs: options?.actionTimeoutMs ?? 45_000, + extraArgs: [], ssrfPolicy: options?.ssrfPolicy, }, }), diff --git a/extensions/browser/src/browser/routes/tabs.ts b/extensions/browser/src/browser/routes/tabs.ts index 45589293f31b..b8986473660d 100644 --- a/extensions/browser/src/browser/routes/tabs.ts +++ b/extensions/browser/src/browser/routes/tabs.ts @@ -13,7 +13,6 @@ import { import { assertBrowserNavigationAllowed, assertBrowserNavigationResultAllowed, - withBrowserNavigationPolicy, } from "../navigation-guard.js"; import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; @@ -101,10 +100,9 @@ async function ensureBrowserRunning( async function redactBlockedTabUrls(params: { tabs: Awaited>; - ssrfPolicy: ReturnType["resolved"]["ssrfPolicy"]; + navigationPolicy: ReturnType; }): Promise>> { - const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy); - if (!ssrfPolicyOpts.ssrfPolicy) { + if (!params.navigationPolicy.ssrfPolicy) { return params.tabs; } @@ -113,7 +111,7 @@ async function redactBlockedTabUrls(params: { try { await assertBrowserNavigationResultAllowed({ url: tab.url, - ...ssrfPolicyOpts, + ...params.navigationPolicy, }); redactedTabs.push(tab); } catch { @@ -213,7 +211,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse } const tabs = await redactBlockedTabUrls({ tabs: await profileCtx.listTabs(), - ssrfPolicy: ctx.state().resolved.ssrfPolicy, + navigationPolicy: browserNavigationPolicyForProfile(ctx, profileCtx), }); res.json({ running: true, tabs }); }, @@ -323,7 +321,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse } const tabs = await redactBlockedTabUrls({ tabs: await profileCtx.listTabs(), - ssrfPolicy: ctx.state().resolved.ssrfPolicy, + navigationPolicy: browserNavigationPolicyForProfile(ctx, profileCtx), }); return res.json({ ok: true, tabs }); }