fix(render): honor proxy environment variables in Chrome launches

This commit is contained in:
Miguel Ángel
2026-10-07 06:12:20 -04:00
parent 1e711b087d
commit ede8ac9959
7 changed files with 363 additions and 9 deletions
@@ -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<number> {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
return (server.address() as AddressInfo).port;
}
async function close(server: Server): Promise<void> {
server.closeAllConnections();
await new Promise<void>((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("<!doctype html><title>loopback-witness</title>");
});
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,
);
});
+3
View File
@@ -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<Browser> {
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,
+1
View File
@@ -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,
@@ -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);
+14 -9
View File
@@ -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<GpuProbeOutcome> {
async function probeAutoBrowserGpuMode(
options: {
chromePath?: string;
browserTimeout?: number;
platform?: NodeJS.Platform;
},
proxyArgs: string[],
): Promise<GpuProbeOutcome> {
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,
@@ -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/);
});
});
@@ -0,0 +1,64 @@
import { isIP } from "node:net";
type ProxyEnvironment = Readonly<Record<string, string | undefined>>;
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(";")}`];
}