diff --git a/src/browser/navigation-guard.test.ts b/src/browser/navigation-guard.test.ts index 58ea7a4cd..8a8350cdb 100644 --- a/src/browser/navigation-guard.test.ts +++ b/src/browser/navigation-guard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js"; import { assertBrowserNavigationAllowed, @@ -12,6 +12,10 @@ function createLookupFn(address: string): LookupFn { } describe("browser navigation guard", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("blocks private loopback URLs by default", async () => { await expect( assertBrowserNavigationAllowed({ @@ -95,6 +99,29 @@ describe("browser navigation guard", () => { expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true }); }); + it("blocks strict policy navigation when env proxy is configured", async () => { + vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); + const lookupFn = createLookupFn("93.184.216.34"); + await expect( + assertBrowserNavigationAllowed({ + url: "https://example.com", + lookupFn, + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + }); + + it("allows env proxy navigation when private-network mode is explicitly enabled", async () => { + vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); + const lookupFn = createLookupFn("93.184.216.34"); + await expect( + assertBrowserNavigationAllowed({ + url: "https://example.com", + lookupFn, + ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, + }), + ).resolves.toBeUndefined(); + }); + it("rejects invalid URLs", async () => { await expect( assertBrowserNavigationAllowed({ diff --git a/src/browser/navigation-guard.ts b/src/browser/navigation-guard.ts index c089cacee..73d1b41ba 100644 --- a/src/browser/navigation-guard.ts +++ b/src/browser/navigation-guard.ts @@ -6,6 +6,28 @@ import { const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]); const SAFE_NON_NETWORK_URLS = new Set(["about:blank"]); +const ENV_PROXY_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", +] as const; + +function hasEnvProxyConfigured(): boolean { + for (const key of ENV_PROXY_KEYS) { + const value = process.env[key]; + if (typeof value === "string" && value.trim().length > 0) { + return true; + } + } + return false; +} + +function allowsPrivateNetworkNavigation(policy?: SsrFPolicy): boolean { + return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; +} function isAllowedNonNetworkNavigationUrl(parsed: URL): boolean { // Keep non-network navigation explicit; about:blank is the only allowed bootstrap URL. @@ -56,6 +78,16 @@ 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 (hasEnvProxyConfigured() && !allowsPrivateNetworkNavigation(opts.ssrfPolicy)) { + throw new InvalidBrowserNavigationUrlError( + "Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set", + ); + } + await resolvePinnedHostnameWithPolicy(parsed.hostname, { lookupFn: opts.lookupFn, policy: opts.ssrfPolicy,