diff --git a/packages/cli/src/browser/launch.proxy.test.ts b/packages/cli/src/browser/launch.proxy.test.ts new file mode 100644 index 00000000000..1976dcadf56 --- /dev/null +++ b/packages/cli/src/browser/launch.proxy.test.ts @@ -0,0 +1,109 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import puppeteer, { type Browser } from "puppeteer-core"; +import { acquireBrowser, type BrowserLease } from "@hyperframes/engine"; +import { launchManagedBrowser } from "./launch.js"; +import { ensureBrowser } from "./manager.js"; + +async function listen(server: Server): Promise { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return (server.address() as AddressInfo).port; +} + +async function close(server: Server): Promise { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); +} + +afterEach(() => vi.unstubAllEnvs()); + +describe("native browser proxy routing", () => { + it("refuses credentials from an explicit launch environment before starting Chrome", async () => { + await expect( + launchManagedBrowser(puppeteer, { env: { HTTPS_PROXY: "http://user:password@proxy.test" } }), + ).rejects.toThrow("HTTPS_PROXY contains proxy credentials."); + }); + + it.each(["cli", "engine"] as const)( + "%s launch routes external fetches through the proxy and bypasses loopback", + async (owner) => { + const requests: string[] = []; + const connections: string[] = []; + const proxy = createServer((request, response) => { + requests.push(request.url ?? ""); + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Content-Type": "text/plain", + }); + response.end("local-proxy-witness"); + }); + proxy.on("connect", (request, socket) => { + connections.push(request.url ?? ""); + socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + }); + const fileServer = createServer((_request, response) => { + response.writeHead(200, { "Content-Type": "text/html" }); + response.end("loopback-witness"); + }); + let browser: Browser | undefined; + let lease: BrowserLease | undefined; + try { + const proxyPort = await listen(proxy); + const filePort = await listen(fileServer); + vi.stubEnv("http_proxy", `http://127.0.0.1:${proxyPort}`); + vi.stubEnv("https_proxy", `http://127.0.0.1:${proxyPort}`); + vi.stubEnv("all_proxy", ""); + vi.stubEnv("no_proxy", ""); + const { executablePath } = await ensureBrowser(); + const args = ["--no-sandbox", "--disable-dev-shm-usage", "--disable-background-networking"]; + if (owner === "cli") { + browser = await launchManagedBrowser(puppeteer, { executablePath, args, headless: true }); + } else { + lease = await acquireBrowser(args, { + chromePath: executablePath, + enableBrowserPool: false, + forceScreenshot: true, + }); + browser = lease.browser; + } + expect(browser.process()?.spawnargs).toContain( + `--proxy-server=http=http://127.0.0.1:${proxyPort};https=http://127.0.0.1:${proxyPort}`, + ); + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${filePort}/`); + expect(await page.title()).toBe("loopback-witness"); + expect( + await page.evaluate(async () => { + const response = await fetch("http://render-network-probe.invalid/probe", { + signal: AbortSignal.timeout(5_000), + }); + return response.text(); + }), + ).toBe("local-proxy-witness"); + expect(requests).toContain("http://render-network-probe.invalid/probe"); + expect(requests.some((url) => url.includes(`127.0.0.1:${filePort}`))).toBe(false); + const denied = await page.evaluate(async () => { + try { + await fetch("https://render-network-probe.invalid/probe", { + signal: AbortSignal.timeout(5_000), + }); + return false; + } catch { + return true; + } + }); + expect(denied).toBe(true); + expect(connections).toContain("render-network-probe.invalid:443"); + } finally { + if (lease) await lease.release(); + else await browser?.close(); + if (fileServer.listening) await close(fileServer); + if (proxy.listening) await close(proxy); + } + }, + 60_000, + ); +}); diff --git a/packages/cli/src/browser/launch.ts b/packages/cli/src/browser/launch.ts index f4b593b492f..320d241ca43 100644 --- a/packages/cli/src/browser/launch.ts +++ b/packages/cli/src/browser/launch.ts @@ -1,3 +1,4 @@ +import { resolveChromeProxyArgs } from "@hyperframes/engine"; import type { Browser, LaunchOptions, PuppeteerNode } from "puppeteer-core"; import { resolveCaptureBrowserGpuMode, @@ -47,11 +48,13 @@ export async function launchManagedBrowser( options: LaunchOptions, ): Promise { if (shutdown) throw new Error("The CLI is stopping; no browser can start."); + const args = [...(options.args ?? []), ...resolveChromeProxyArgs(options.env ?? process.env)]; ownSignals(); const abort = new AbortController(); const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; const launch = puppeteer.launch({ ...options, + args, signal, handleSIGINT: false, handleSIGTERM: false, diff --git a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts index 41b611215ce..6373fe65e76 100644 --- a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts @@ -18,10 +18,8 @@ vi.mock("../browser/manager.js", () => ({ vi.mock("puppeteer-core", () => ({ default: { launch: mocks.launch } })); -// captureCompositionFrame.js imports these two from ../browser/gpuPolicy.js, -// which re-exports them from this mocked module — unmocked here, they'd read -// undefined and throw as soon as openSettledCompositionPage calls them. -vi.mock("@hyperframes/engine", () => ({ +vi.mock("@hyperframes/engine", async (importOriginal) => ({ + ...(await importOriginal()), buildChromeArgs: mocks.buildChromeArgs, resolveBrowserGpuMode: mocks.resolveBrowserGpuMode, compositionRequiresWebGpu: mocks.compositionRequiresWebGpu, diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 0f952ca5dd1..b3f7c23d94c 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -74,6 +74,7 @@ export { } from "./services/systemMemory.js"; // ── Browser management ───────────────────────────────────────────────────────── +export { resolveChromeProxyArgs } from "./services/chromeProxy.js"; export { chromeMajorCeiling } from "./services/chromeHostCeiling.js"; export { acquireBrowser, diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index 08bb47d7262..feb5739041b 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -369,6 +369,49 @@ describe("resolveBrowserGpuMode", () => { _resetAutoBrowserGpuModeCacheForTests(); }); + it("routes the GPU probe through the shared proxy policy", async () => { + const { launch } = setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA" }); + vi.stubEnv("http_proxy", ""); + vi.stubEnv("https_proxy", "http://proxy.test:8080"); + vi.stubEnv("all_proxy", ""); + try { + await resolveBrowserGpuMode("auto"); + expect(launch.mock.calls[0]?.[0].args).toContain( + "--proxy-server=https=http://proxy.test:8080", + ); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("refuses proxy credentials before the GPU probe without masking the error", async () => { + const { launch } = setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA" }); + vi.stubEnv("https_proxy", "http://user:password@proxy.test"); + try { + await expect(resolveBrowserGpuMode("auto")).rejects.toThrow( + "HTTPS_PROXY contains proxy credentials.", + ); + expect(launch).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("a refused proxy does not poison the next GPU probe after the environment is corrected", async () => { + const { launch } = setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA" }); + vi.stubEnv("https_proxy", "http://user:password@proxy.test"); + try { + await expect(resolveBrowserGpuMode("auto")).rejects.toThrow( + "HTTPS_PROXY contains proxy credentials.", + ); + vi.stubEnv("https_proxy", "http://proxy.test:8080"); + await expect(resolveBrowserGpuMode("auto")).resolves.toBe("hardware"); + expect(launch).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllEnvs(); + } + }); + it("passes 'software' through unchanged without probing", async () => { const mode = await resolveBrowserGpuMode("software"); expect(mode).toBe("software"); @@ -882,6 +925,29 @@ describe("browser pool", () => { _setPuppeteerForTests(undefined); }); + it("includes resolved environment proxy flags in the pool identity", async () => { + vi.stubEnv("http_proxy", "http://127.0.0.1:18001"); + vi.stubEnv("https_proxy", "http://127.0.0.1:18001"); + vi.stubEnv("all_proxy", ""); + vi.stubEnv("no_proxy", "example.test"); + try { + const first = await acquireBrowser(["--no-sandbox"], poolCfg); + vi.stubEnv("https_proxy", "http://127.0.0.1:18002"); + const second = await acquireBrowser(["--no-sandbox"], poolCfg); + expect(launchFn).toHaveBeenCalledTimes(2); + expect(launchFn.mock.calls[0]?.[0].args).toContain( + "--proxy-server=http=http://127.0.0.1:18001;https=http://127.0.0.1:18001", + ); + expect(launchFn.mock.calls[1]?.[0].args).toContain( + "--proxy-server=http=http://127.0.0.1:18001;https=http://127.0.0.1:18002", + ); + await first.release(); + await second.release(); + } finally { + vi.unstubAllEnvs(); + } + }); + it("sequential acquires with pool enabled return the same browser", async () => { const first = await acquireBrowser(["--no-sandbox"], poolCfg); const second = await acquireBrowser(["--no-sandbox"], poolCfg); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 5719f473c3a..69d89747bbd 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -11,6 +11,7 @@ import { execSync } from "child_process"; import { existsSync, readdirSync } from "fs"; import { join } from "path"; import { homedir } from "os"; +import { resolveChromeProxyArgs } from "./chromeProxy.js"; import { chromeMajorCeiling, exceedsChromeCeiling } from "./chromeHostCeiling.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { getSystemTotalMb, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js"; @@ -505,11 +506,14 @@ function formatProbeFailure(err: unknown): string { return `probe failed (${err instanceof Error ? err.message : String(err)})`; } -async function probeAutoBrowserGpuMode(options: { - chromePath?: string; - browserTimeout?: number; - platform?: NodeJS.Platform; -}): Promise { +async function probeAutoBrowserGpuMode( + options: { + chromePath?: string; + browserTimeout?: number; + platform?: NodeJS.Platform; + }, + proxyArgs: string[], +): Promise { const platform = options.platform ?? process.platform; const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout; const executablePath = options.chromePath ?? resolveHeadlessShellPath({}); @@ -522,7 +526,7 @@ async function probeAutoBrowserGpuMode(options: { try { const info = await probeHardwareWebGlInfo(ppt, { - args: getHardwareGpuProbeArgs(platform), + args: [...getHardwareGpuProbeArgs(platform), ...proxyArgs], browserTimeout, executablePath, }); @@ -561,7 +565,7 @@ async function probeAutoBrowserGpuMode(options: { * safe failure mode; misclassifying toward hardware would error on the real * render. */ -export function resolveBrowserGpuMode( +export async function resolveBrowserGpuMode( mode: EngineConfig["browserGpuMode"], options: { chromePath?: string; @@ -571,7 +575,8 @@ export function resolveBrowserGpuMode( ): Promise<"software" | "hardware"> { if (mode === "software") return Promise.resolve(mode); - _autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options); + const proxyArgs = resolveChromeProxyArgs(process.env); + _autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options, proxyArgs); if (mode === "auto") return _autoBrowserGpuModeCache.then((probed) => probed.mode); return _autoBrowserGpuModeCache.then((probed) => { @@ -676,7 +681,7 @@ function createBrowserLaunchFingerprint( ? "beginframe" : "screenshot"; return { - args: chromeArgs, + args: [...chromeArgs, ...resolveChromeProxyArgs(process.env)], executablePath: headlessShell, browserTimeoutMs: launchConfig.browserTimeout, protocolTimeoutMs: launchConfig.protocolTimeout, diff --git a/packages/engine/src/services/chromeProxy.test.ts b/packages/engine/src/services/chromeProxy.test.ts new file mode 100644 index 00000000000..561bf46641a --- /dev/null +++ b/packages/engine/src/services/chromeProxy.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { resolveChromeProxyArgs } from "./chromeProxy.js"; + +const bypass = "--proxy-bypass-list=localhost;127.0.0.1;[::1]"; + +describe("Chrome proxy environment", () => { + it.each([ + [{}, []], + [{ NO_PROXY: "example.test" }, []], + [{ HTTP_PROXY: "proxy.test:8080" }, ["--proxy-server=http=http://proxy.test:8080", bypass]], + [ + { HTTPS_PROXY: "http://secure.test:8080" }, + ["--proxy-server=https=http://secure.test:8080", bypass], + ], + [ + { ALL_PROXY: "socks5://proxy.test:1080" }, + [ + "--proxy-server=http=socks5://proxy.test:1080;https=socks5://proxy.test:1080;socks=socks5://proxy.test:1080", + bypass, + ], + ], + [ + { HTTP_PROXY: "http://one.test:80", HTTPS_PROXY: "https://two.test:443" }, + ["--proxy-server=http=http://one.test;https=https://two.test", bypass], + ], + [ + { + ALL_PROXY: "http://all.test:8000", + https_proxy: "http://secure.test:8001", + }, + [ + "--proxy-server=http=http://all.test:8000;https=http://secure.test:8001;socks=http://all.test:8000", + bypass, + ], + ], + [ + { + http_proxy: "http://lower.test:81", + HTTP_PROXY: "http://upper.test:82", + }, + ["--proxy-server=http=http://lower.test:81", bypass], + ], + [{ https_proxy: "", HTTPS_PROXY: "http://upper.test:82" }, []], + [ + { all_proxy: "http://lower.test:81", ALL_PROXY: "http://upper.test:82" }, + [ + "--proxy-server=http=http://lower.test:81;https=http://lower.test:81;socks=http://lower.test:81", + bypass, + ], + ], + ])("maps %j to native Chrome flags", (env, args) => { + expect(resolveChromeProxyArgs(env)).toEqual(args); + }); + + it("always bypasses loopback and preserves lower-case NO_PROXY entries", () => { + expect( + resolveChromeProxyArgs({ + HTTPS_PROXY: "http://proxy.test:80", + no_proxy: " .example.test, localhost, 10.0.0.0/8, [::1] ", + NO_PROXY: "ignored.test", + }), + ).toEqual([ + "--proxy-server=https=http://proxy.test", + "--proxy-bypass-list=example.test;.example.test;.localhost;10.0.0.0/8;localhost;127.0.0.1;[::1]", + ]); + }); + + it("matches NO_PROXY domains, IPv6 literals and ports without losing required loopback bypass", () => { + expect( + resolveChromeProxyArgs({ + HTTP_PROXY: "http://proxy.test", + NO_PROXY: "example.test,::1,192.0.2.1:8080,<-loopback>", + })[1], + ).toBe( + "--proxy-bypass-list=example.test;.example.test;192.0.2.1:8080;<-loopback>;localhost;127.0.0.1;[::1]", + ); + }); + + it.each(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"])( + "rejects %s credentials without exposing them", + (name) => { + const operation = () => + resolveChromeProxyArgs({ + [name]: "http://secret-user:secret-pass@proxy.test:80", + }); + expect(operation).toThrow(`${name} contains proxy credentials.`); + try { + operation(); + } catch (error) { + expect(String(error)).not.toContain("secret-user"); + expect(String(error)).not.toContain("secret-pass"); + } + }, + ); + + it.each([ + "ftp://proxy.test", + "http://proxy.test/path", + "http://proxy.test/?query", + "http://proxy.test/#fragment", + "http://[", + "http://proxy.test;direct", + ])("rejects invalid proxy %s", (value) => { + expect(() => resolveChromeProxyArgs({ HTTPS_PROXY: value })).toThrow(/HTTPS_PROXY must be/); + }); +}); diff --git a/packages/engine/src/services/chromeProxy.ts b/packages/engine/src/services/chromeProxy.ts new file mode 100644 index 00000000000..62f71c85f32 --- /dev/null +++ b/packages/engine/src/services/chromeProxy.ts @@ -0,0 +1,64 @@ +import { isIP } from "node:net"; + +type ProxyEnvironment = Readonly>; + +function validProxyEndpoint(url: URL): boolean { + return ( + ["http:", "https:", "socks4:", "socks5:"].includes(url.protocol) && + url.hostname !== "" && + !/[;,]/.test(url.host) && + ["", "/"].includes(url.pathname) && + url.search === "" && + url.hash === "" + ); +} + +function proxySetting(env: ProxyEnvironment, name: string): string | undefined { + const setting = env[name.toLowerCase()] ?? env[name]; + if (setting === undefined) return undefined; + const value = setting.trim(); + if (value === "") return undefined; + let url: URL; + try { + url = new URL(value.includes("://") ? value : `http://${value}`); + } catch { + throw new Error(`${name} must be a valid proxy URL.`); + } + if (url.username !== "" || url.password !== "") { + throw new Error( + `${name} contains proxy credentials. Chrome ignores embedded credentials; use a proxy without URL credentials.`, + ); + } + if (!validProxyEndpoint(url)) { + throw new Error( + `${name} must be an HTTP, HTTPS, SOCKS4 or SOCKS5 proxy URL without a path, query or fragment.`, + ); + } + return `${url.protocol}//${url.host}`; +} + +function bypassRules(host: string): string[] { + if (isIP(host) === 6) return [`[${host}]`]; + if (isIP(host.replace(/:\d+$/, "")) !== 0 || /[/*<[]/.test(host)) return [host]; + const domain = host.replace(/^\./, ""); + return [domain, `.${domain}`]; +} + +export function resolveChromeProxyArgs(env: ProxyEnvironment): string[] { + const all = proxySetting(env, "ALL_PROXY"); + const http = proxySetting(env, "HTTP_PROXY") ?? all; + const https = proxySetting(env, "HTTPS_PROXY") ?? all; + const servers: string[] = []; + if (http !== undefined) servers.push(`http=${http}`); + if (https !== undefined) servers.push(`https=${https}`); + if (all !== undefined) servers.push(`socks=${all}`); + if (servers.length === 0) return []; + const exclusions = (env.no_proxy ?? env.NO_PROXY ?? "") + .split(",") + .map((host) => host.trim()) + .filter((host) => host !== "") + .flatMap(bypassRules); + const loopback = ["localhost", "127.0.0.1", "[::1]"]; + const bypass = [...new Set(exclusions.filter((host) => !loopback.includes(host))), ...loopback]; + return [`--proxy-server=${servers.join(";")}`, `--proxy-bypass-list=${bypass.join(";")}`]; +}