From a98a0b94d1bfa790bfdc18ed893731c7bbdcf230 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 03:48:48 +0100 Subject: [PATCH] fix: isolate browser proxy routing Co-authored-by: Sanjays2402 --- CHANGELOG.md | 1 + docs/tools/browser.md | 2 + .../src/browser/browser-proxy-mode.test.ts | 53 +++++++++++++++++ .../browser/src/browser/browser-proxy-mode.ts | 55 ++++++++++++++++++ .../src/browser/chrome.internal.test.ts | 17 ++++++ extensions/browser/src/browser/chrome.ts | 6 +- .../src/browser/navigation-guard.test.ts | 21 +++++-- .../browser/src/browser/navigation-guard.ts | 27 ++++++--- extensions/browser/src/browser/pw-session.ts | 58 +++++++++++-------- .../src/browser/pw-tools-core.snapshot.ts | 13 ++++- .../src/browser/routes/agent.snapshot.ts | 20 +++++-- extensions/browser/src/browser/routes/tabs.ts | 16 ++++- .../src/browser/server-context.tab-ops.ts | 12 +++- 13 files changed, 253 insertions(+), 48 deletions(-) create mode 100644 extensions/browser/src/browser/browser-proxy-mode.test.ts create mode 100644 extensions/browser/src/browser/browser-proxy-mode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3600d3ce7e6..46d76b061e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Browser/sandbox: pass the resolved `browser.ssrfPolicy` into sandbox browser bridges and refresh cached bridges when the effective policy changes, so sandboxed browser navigation honors private-network opt-ins. Fixes #45153 and #57055. Thanks @jzakirov, @zuoanCo, and @kybrcore. +- Browser/proxy: keep Gateway/provider proxy environment variables from proxying the OpenClaw-managed browser, so `HTTP_PROXY` and `HTTPS_PROXY` no longer block ordinary browser navigation. Fixes #71358. Thanks @Sanjays2402. - Dashboard/Windows: open Control UI and OAuth URLs through the system URL handler without `cmd.exe` parsing or PATH-based `rundll32` lookup, and reject non-HTTP browser-open inputs. Fixes #71098. Thanks @Sanjays2402. - Config/doctor: reject legacy `secretref-env:` marker strings on SecretRef credential paths and migrate valid markers to structured env SecretRefs with `openclaw doctor --fix`. Fixes #51794. Thanks @halointellicore. - Providers/OpenAI: separate API-key and Codex sign-in onboarding groups, and avoid replaying stale OpenAI Responses reasoning blocks after a model route switch. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 2180ea4a357..1fe77fe2053 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -176,6 +176,8 @@ Browser settings live in `~/.openclaw/openclaw.json`. - Browser navigation and open-tab are SSRF-guarded before navigation and best-effort re-checked on the final `http(s)` URL afterwards. - In strict SSRF mode, remote CDP endpoint discovery and `/json/version` probes (`cdpUrl`) are checked too. +- Gateway/provider `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` environment variables do not automatically proxy the OpenClaw-managed browser. Managed Chrome launches direct by default so provider proxy settings do not weaken browser SSRF checks. +- To proxy the managed browser itself, pass explicit Chrome proxy flags through `browser.extraArgs`, such as `--proxy-server=...` or `--proxy-pac-url=...`. Strict SSRF mode blocks explicit browser proxy routing unless private-network browser access is intentionally enabled. - `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` is off by default; enable only when private-network browser access is intentionally trusted. - `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias. diff --git a/extensions/browser/src/browser/browser-proxy-mode.test.ts b/extensions/browser/src/browser/browser-proxy-mode.test.ts new file mode 100644 index 00000000000..f7fc0f93b24 --- /dev/null +++ b/extensions/browser/src/browser/browser-proxy-mode.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + hasChromeProxyControlArg, + hasExplicitChromeProxyRoutingArg, + omitChromeProxyEnv, + resolveBrowserNavigationProxyMode, +} from "./browser-proxy-mode.js"; + +describe("browser proxy mode", () => { + it("detects Chrome proxy-routing args separately from direct proxy controls", () => { + expect(hasChromeProxyControlArg(["--no-proxy-server"])).toBe(true); + expect(hasExplicitChromeProxyRoutingArg(["--no-proxy-server"])).toBe(false); + expect(hasExplicitChromeProxyRoutingArg(["--proxy-server=http://127.0.0.1:7890"])).toBe(true); + expect(hasExplicitChromeProxyRoutingArg(["--proxy-pac-url", "http://proxy.test/pac"])).toBe( + true, + ); + }); + + it("removes proxy env before launching managed Chrome", () => { + const env = omitChromeProxyEnv({ + HTTP_PROXY: "http://proxy.test:8080", + HTTPS_PROXY: "http://proxy.test:8443", + ALL_PROXY: "socks5://proxy.test:1080", + NO_PROXY: "localhost", + PATH: "/usr/bin", + http_proxy: "http://lower.test:8080", + no_proxy: "127.0.0.1", + }); + expect(env).toEqual({ PATH: "/usr/bin" }); + }); + + it("marks only managed local Chrome with explicit proxy routing as proxy-routed", () => { + const resolved = { extraArgs: ["--proxy-server=http://127.0.0.1:7890"] }; + expect( + resolveBrowserNavigationProxyMode({ + resolved, + profile: { driver: "openclaw", cdpIsLoopback: true }, + }), + ).toBe("explicit-browser-proxy"); + expect( + resolveBrowserNavigationProxyMode({ + resolved, + profile: { driver: "existing-session", cdpIsLoopback: true }, + }), + ).toBe("direct"); + expect( + resolveBrowserNavigationProxyMode({ + resolved, + profile: { driver: "openclaw", cdpIsLoopback: false }, + }), + ).toBe("direct"); + }); +}); diff --git a/extensions/browser/src/browser/browser-proxy-mode.ts b/extensions/browser/src/browser/browser-proxy-mode.ts new file mode 100644 index 00000000000..e1c830a4f9a --- /dev/null +++ b/extensions/browser/src/browser/browser-proxy-mode.ts @@ -0,0 +1,55 @@ +import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; +import type { BrowserNavigationProxyMode } from "./navigation-guard.js"; + +const PROXY_ROUTING_CHROME_ARGS = new Set([ + "--proxy-auto-detect", + "--proxy-pac-url", + "--proxy-server", +]); + +const PROXY_CONTROL_CHROME_ARGS = new Set(["--no-proxy-server", ...PROXY_ROUTING_CHROME_ARGS]); + +export const CHROME_PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +] as const; + +function chromeArgName(arg: string): string { + return arg.trim().split("=", 1)[0]?.toLowerCase() ?? ""; +} + +export function hasChromeProxyControlArg(args: readonly string[]): boolean { + return args.some((arg) => PROXY_CONTROL_CHROME_ARGS.has(chromeArgName(arg))); +} + +export function hasExplicitChromeProxyRoutingArg(args: readonly string[]): boolean { + return args.some((arg) => PROXY_ROUTING_CHROME_ARGS.has(chromeArgName(arg))); +} + +export function omitChromeProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...env }; + for (const key of CHROME_PROXY_ENV_KEYS) { + delete next[key]; + } + return next; +} + +export function resolveBrowserNavigationProxyMode(params: { + resolved: Pick; + profile: Pick; +}): BrowserNavigationProxyMode { + if ( + params.profile.driver === "openclaw" && + params.profile.cdpIsLoopback && + hasExplicitChromeProxyRoutingArg(params.resolved.extraArgs) + ) { + return "explicit-browser-proxy"; + } + return "direct"; +} diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index a8be83257f0..74fbdb34553 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -224,6 +224,16 @@ describe("chrome.ts internal", () => { }); expect(args).toContain("--proxy-server=http://localhost:3128"); expect(args).toContain("--mute-audio"); + expect(args).not.toContain("--no-proxy-server"); + }); + + it("launches managed Chrome direct by default", () => { + const args = buildOpenClawChromeLaunchArgs({ + resolved: baseResolved(), + profile: baseProfile, + userDataDir: "/tmp/foo", + }); + expect(args).toContain("--no-proxy-server"); }); }); @@ -354,6 +364,9 @@ describe("chrome.ts internal", () => { spawnCalls += 1; return makeFakeProc(); }); + vi.stubEnv("HTTP_PROXY", "http://proxy.test:8080"); + vi.stubEnv("HTTPS_PROXY", "http://proxy.test:8443"); + vi.stubEnv("NO_PROXY", "localhost"); // Set up a real HTTP server impersonating Chrome's /json/version. await withMockChromeCdpServer({ @@ -364,6 +377,10 @@ describe("chrome.ts internal", () => { const running = await launchOpenClawChrome(makeResolved(), profile); expect(running.pid).toBe(4242); expect(spawnCalls).toBeGreaterThanOrEqual(1); + const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv }; + expect(spawnOptions.env?.HTTP_PROXY).toBeUndefined(); + expect(spawnOptions.env?.HTTPS_PROXY).toBeUndefined(); + expect(spawnOptions.env?.NO_PROXY).toBeUndefined(); // Cleanup. running.proc.kill?.("SIGTERM"); }, diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 183549cb180..7e88859d79c 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -8,6 +8,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { ensurePortAvailable } from "../infra/ports.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { CONFIG_DIR } from "../utils.js"; +import { hasChromeProxyControlArg, omitChromeProxyEnv } from "./browser-proxy-mode.js"; import { CHROME_BOOTSTRAP_EXIT_POLL_MS, CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS, @@ -132,6 +133,9 @@ export function buildOpenClawChromeLaunchArgs(params: { if (process.platform === "linux") { args.push("--disable-dev-shm-usage"); } + if (!hasChromeProxyControlArg(resolved.extraArgs)) { + args.push("--no-proxy-server"); + } if (resolved.extraArgs.length > 0) { args.push(...resolved.extraArgs); } @@ -293,7 +297,7 @@ export async function launchOpenClawChrome( // the tuple overload resolution varies across @types/node versions. const preparedSpawn = prepareOomScoreAdjustedSpawn(exe.path, args, { env: { - ...process.env, + ...omitChromeProxyEnv(process.env), // Reduce accidental sharing with the user's env. HOME: os.homedir(), }, diff --git a/extensions/browser/src/browser/navigation-guard.test.ts b/extensions/browser/src/browser/navigation-guard.test.ts index 34b2e0f1890..c815648fce5 100644 --- a/extensions/browser/src/browser/navigation-guard.test.ts +++ b/extensions/browser/src/browser/navigation-guard.test.ts @@ -207,7 +207,7 @@ describe("browser navigation guard", () => { ).resolves.toBeUndefined(); }); - it("blocks strict policy navigation when env proxy is configured", async () => { + it("allows public navigation when only Gateway env proxy is configured", async () => { vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); const lookupFn = createLookupFn("93.184.216.34"); await expect( @@ -215,16 +215,29 @@ describe("browser navigation guard", () => { url: "https://example.com", lookupFn, }), - ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + ).resolves.toBeUndefined(); + expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true }); }); - it("allows env proxy navigation when private-network mode is explicitly enabled", async () => { - vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); + it("blocks explicit browser proxy routing in strict SSRF mode", async () => { const lookupFn = createLookupFn("93.184.216.34"); await expect( assertBrowserNavigationAllowed({ url: "https://example.com", lookupFn, + browserProxyMode: "explicit-browser-proxy", + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + expect(lookupFn).not.toHaveBeenCalled(); + }); + + it("allows explicit browser proxy routing when private-network mode is enabled", async () => { + const lookupFn = createLookupFn("93.184.216.34"); + await expect( + assertBrowserNavigationAllowed({ + url: "https://example.com", + lookupFn, + browserProxyMode: "explicit-browser-proxy", ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, }), ).resolves.toBeUndefined(); diff --git a/extensions/browser/src/browser/navigation-guard.ts b/extensions/browser/src/browser/navigation-guard.ts index 29989eae66f..b9337ab6448 100644 --- a/extensions/browser/src/browser/navigation-guard.ts +++ b/extensions/browser/src/browser/navigation-guard.ts @@ -3,7 +3,6 @@ import { matchesHostnameAllowlist, normalizeHostname, } from "openclaw/plugin-sdk/browser-security-runtime"; -import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; import { isPrivateNetworkAllowedByPolicy, resolvePinnedHostnameWithPolicy, @@ -32,8 +31,11 @@ export class InvalidBrowserNavigationUrlError extends Error { export type BrowserNavigationPolicyOptions = { ssrfPolicy?: SsrFPolicy; + browserProxyMode?: BrowserNavigationProxyMode; }; +export type BrowserNavigationProxyMode = "direct" | "explicit-browser-proxy"; + export type BrowserNavigationRequestLike = { url(): string; redirectedFrom(): BrowserNavigationRequestLike | null; @@ -41,8 +43,14 @@ export type BrowserNavigationRequestLike = { export function withBrowserNavigationPolicy( ssrfPolicy?: SsrFPolicy, + opts?: { browserProxyMode?: BrowserNavigationProxyMode }, ): BrowserNavigationPolicyOptions { - return ssrfPolicy ? { ssrfPolicy } : {}; + return { + ...(ssrfPolicy ? { ssrfPolicy } : {}), + ...(opts?.browserProxyMode && opts.browserProxyMode !== "direct" + ? { browserProxyMode: opts.browserProxyMode } + : {}), + }; } export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean { @@ -109,13 +117,15 @@ export async function assertBrowserNavigationAllowed( ); } - // Browser network stacks may apply env proxy routing at connect-time, which - // can bypass strict destination-binding intent from pre-navigation DNS checks. - // In strict mode, fail closed unless private-network navigation is explicitly - // enabled by policy. - if (hasProxyEnvConfigured() && !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy)) { + // Browser proxy routing hides the final connect target from this process. + // Only block when the browser profile is known to be proxy-routed; Gateway + // provider proxy env alone is not proof of browser page proxy behavior. + if ( + opts.browserProxyMode === "explicit-browser-proxy" && + !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy) + ) { throw new InvalidBrowserNavigationUrlError( - "Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set", + "Navigation blocked: strict browser SSRF policy cannot be enforced while this browser profile is proxy-routed", ); } @@ -188,6 +198,7 @@ export async function assertBrowserNavigationRedirectChainAllowed( url, lookupFn: opts.lookupFn, ssrfPolicy: opts.ssrfPolicy, + browserProxyMode: opts.browserProxyMode, }); } } diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index 3ab151e81d7..6a240581473 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -27,6 +27,7 @@ import { assertBrowserNavigationAllowed, assertBrowserNavigationRedirectChainAllowed, assertBrowserNavigationResultAllowed, + type BrowserNavigationPolicyOptions, InvalidBrowserNavigationUrlError, withBrowserNavigationPolicy, } from "./navigation-guard.js"; @@ -747,14 +748,17 @@ async function closeBlockedNavigationTarget(opts: { await opts.page.close().catch(() => {}); } -export async function assertPageNavigationCompletedSafely(opts: { - cdpUrl: string; - page: Page; - response: Response | null; - ssrfPolicy?: SsrFPolicy; - targetId?: string; -}): Promise { - const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy); +export async function assertPageNavigationCompletedSafely( + opts: { + cdpUrl: string; + page: Page; + response: Response | null; + targetId?: string; + } & BrowserNavigationPolicyOptions, +): Promise { + const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy, { + browserProxyMode: opts.browserProxyMode, + }); try { await assertBrowserNavigationRedirectChainAllowed({ request: opts.response?.request(), @@ -776,15 +780,18 @@ export async function assertPageNavigationCompletedSafely(opts: { } } -export async function gotoPageWithNavigationGuard(opts: { - cdpUrl: string; - page: Page; - url: string; - timeoutMs: number; - ssrfPolicy?: SsrFPolicy; - targetId?: string; -}): Promise { - const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy); +export async function gotoPageWithNavigationGuard( + opts: { + cdpUrl: string; + page: Page; + url: string; + timeoutMs: number; + targetId?: string; + } & BrowserNavigationPolicyOptions, +): Promise { + const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy, { + browserProxyMode: opts.browserProxyMode, + }); let blockedError: unknown = null; const handler = async (route: Route, request: Request) => { @@ -1102,11 +1109,12 @@ export async function listPagesViaPlaywright(opts: { * Used for remote profiles where HTTP-based /json/new is ephemeral. * Returns the new page's targetId and metadata. */ -export async function createPageViaPlaywright(opts: { - cdpUrl: string; - url: string; - ssrfPolicy?: SsrFPolicy; -}): Promise<{ +export async function createPageViaPlaywright( + opts: { + cdpUrl: string; + url: string; + } & BrowserNavigationPolicyOptions, +): Promise<{ targetId: string; title: string; url: string; @@ -1125,7 +1133,9 @@ export async function createPageViaPlaywright(opts: { // Navigate to the URL const targetUrl = opts.url.trim() || "about:blank"; if (targetUrl !== "about:blank") { - const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy); + const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy, { + browserProxyMode: opts.browserProxyMode, + }); await assertBrowserNavigationAllowed({ url: targetUrl, ...navigationPolicy, @@ -1138,6 +1148,7 @@ export async function createPageViaPlaywright(opts: { url: targetUrl, timeoutMs: 30_000, ssrfPolicy: opts.ssrfPolicy, + browserProxyMode: opts.browserProxyMode, targetId: createdTargetId ?? undefined, }); } catch (err) { @@ -1150,6 +1161,7 @@ export async function createPageViaPlaywright(opts: { page, response, ssrfPolicy: opts.ssrfPolicy, + browserProxyMode: opts.browserProxyMode, targetId: createdTargetId ?? undefined, }); } diff --git a/extensions/browser/src/browser/pw-tools-core.snapshot.ts b/extensions/browser/src/browser/pw-tools-core.snapshot.ts index f4bb5717ff2..447b7c6a830 100644 --- a/extensions/browser/src/browser/pw-tools-core.snapshot.ts +++ b/extensions/browser/src/browser/pw-tools-core.snapshot.ts @@ -2,7 +2,11 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { Page } from "playwright-core"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js"; -import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js"; +import { + assertBrowserNavigationAllowed, + type BrowserNavigationPolicyOptions, + withBrowserNavigationPolicy, +} from "./navigation-guard.js"; import { buildRoleSnapshotFromAiSnapshot, buildRoleSnapshotFromAriaSnapshot, @@ -241,6 +245,7 @@ export async function navigateViaPlaywright(opts: { url: string; timeoutMs?: number; ssrfPolicy?: SsrFPolicy; + browserProxyMode?: BrowserNavigationPolicyOptions["browserProxyMode"]; }): Promise<{ url: string }> { const isRetryableNavigateError = (err: unknown): boolean => { const msg = @@ -261,7 +266,9 @@ export async function navigateViaPlaywright(opts: { } await assertBrowserNavigationAllowed({ url, - ...withBrowserNavigationPolicy(opts.ssrfPolicy), + ...withBrowserNavigationPolicy(opts.ssrfPolicy, { + browserProxyMode: opts.browserProxyMode, + }), }); const timeout = Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)); let page = await getPageForTargetId(opts); @@ -273,6 +280,7 @@ export async function navigateViaPlaywright(opts: { url, timeoutMs: timeout, ssrfPolicy: opts.ssrfPolicy, + browserProxyMode: opts.browserProxyMode, targetId: opts.targetId, }); let response; @@ -298,6 +306,7 @@ export async function navigateViaPlaywright(opts: { page, response, ssrfPolicy: opts.ssrfPolicy, + browserProxyMode: opts.browserProxyMode, targetId: opts.targetId, }); const finalUrl = page.url(); diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index 3db2030b4f4..9999842c690 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; +import { resolveBrowserNavigationProxyMode } from "../browser-proxy-mode.js"; import { captureScreenshot, snapshotAria } from "../cdp.js"; import { evaluateChromeMcpScript, @@ -23,7 +24,7 @@ import { DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, normalizeBrowserScreenshot, } from "../screenshot.js"; -import type { BrowserRouteContext } from "../server-context.js"; +import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import { getPwAiModule, handleRouteError, @@ -45,6 +46,15 @@ import { asyncBrowserRoute, jsonError, toBoolean, toNumber, toStringOrEmpty } fr const CHROME_MCP_OVERLAY_ATTR = "data-openclaw-mcp-overlay"; +function browserNavigationPolicyForProfile(ctx: BrowserRouteContext, profileCtx: ProfileContext) { + return withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy, { + browserProxyMode: resolveBrowserNavigationProxyMode({ + resolved: ctx.state().resolved, + profile: profileCtx.profile, + }), + }); +} + async function collectChromeMcpSnapshotUrls(params: { profileName: string; userDataDir?: string; @@ -251,7 +261,7 @@ export function registerBrowserAgentSnapshotRoutes( targetId, run: async ({ profileCtx, tab, cdpUrl }) => { if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy); + const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx); await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const result = await navigateChromeMcpPage({ profileName: profileCtx.profile.name, @@ -270,7 +280,7 @@ export function registerBrowserAgentSnapshotRoutes( cdpUrl, targetId: tab.targetId, url, - ...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy), + ...browserNavigationPolicyForProfile(ctx, profileCtx), }); const currentTargetId = await resolveTargetIdAfterNavigate({ oldTargetId: tab.targetId, @@ -346,7 +356,7 @@ export function registerBrowserAgentSnapshotRoutes( targetId, run: async ({ profileCtx, tab, cdpUrl }) => { if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy); + const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx); if (element) { return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement); } @@ -508,7 +518,7 @@ export function registerBrowserAgentSnapshotRoutes( return jsonError(res, 400, "labels/mode=efficient require format=ai"); } if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { - const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy); + const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx); if (plan.selectorValue || plan.frameSelectorValue) { return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector); } diff --git a/extensions/browser/src/browser/routes/tabs.ts b/extensions/browser/src/browser/routes/tabs.ts index 664641e1bf7..56d0a9ae58f 100644 --- a/extensions/browser/src/browser/routes/tabs.ts +++ b/extensions/browser/src/browser/routes/tabs.ts @@ -1,3 +1,4 @@ +import { resolveBrowserNavigationProxyMode } from "../browser-proxy-mode.js"; import { BrowserProfileUnavailableError, BrowserTabNotFoundError, @@ -32,6 +33,15 @@ function resolveTabsProfileContext( return profileCtx; } +function browserNavigationPolicyForProfile(ctx: BrowserRouteContext, profileCtx: ProfileContext) { + return withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy, { + browserProxyMode: resolveBrowserNavigationProxyMode({ + resolved: ctx.state().resolved, + profile: profileCtx.profile, + }), + }); +} + function handleTabsRouteError( ctx: BrowserRouteContext, res: BrowserResponse, @@ -188,7 +198,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse run: async (profileCtx) => { await assertBrowserNavigationAllowed({ url, - ...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy), + ...browserNavigationPolicyForProfile(ctx, profileCtx), }); await profileCtx.ensureBrowserAvailable(); const tab = await profileCtx.openTab(url, { label }); @@ -223,7 +233,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse if (!tab) { throw new BrowserTabNotFoundError({ input: id }); } - const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy); + const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx); if (ssrfPolicyOpts.ssrfPolicy) { await assertBrowserNavigationResultAllowed({ url: tab.url, @@ -331,7 +341,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse if (!target) { throw new BrowserTabNotFoundError(); } - const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy); + const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx); if (ssrfPolicyOpts.ssrfPolicy) { await assertBrowserNavigationResultAllowed({ url: target.url, diff --git a/extensions/browser/src/browser/server-context.tab-ops.ts b/extensions/browser/src/browser/server-context.tab-ops.ts index c1ff2c24ac5..3237fa093c7 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 { resolveBrowserNavigationProxyMode } from "./browser-proxy-mode.js"; import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js"; import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; import { @@ -132,6 +133,13 @@ export function createProfileTabOps({ const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); const capabilities = getBrowserProfileCapabilities(profile); const getCdpControlPolicy = () => resolveCdpControlPolicy(profile, state().resolved.ssrfPolicy); + const getNavigationPolicy = () => + withBrowserNavigationPolicy(state().resolved.ssrfPolicy, { + browserProxyMode: resolveBrowserNavigationProxyMode({ + resolved: state().resolved, + profile, + }), + }); const readTabs = async (): Promise => { if (capabilities.usesChromeMcp) { @@ -218,7 +226,7 @@ export function createProfileTabOps({ }; const openTab = async (url: string, opts?: { label?: string }): Promise => { - const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy); + const ssrfPolicyOpts = getNavigationPolicy(); if (capabilities.usesChromeMcp) { await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); @@ -261,6 +269,7 @@ export function createProfileTabOps({ ); } + await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const createdViaCdp = await createTargetViaCdp({ cdpUrl: profile.cdpUrl, url, @@ -293,7 +302,6 @@ export function createProfileTabOps({ const encoded = encodeURIComponent(url); const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new")); - await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const endpoint = endpointUrl.search ? (() => { endpointUrl.searchParams.set("url", url);