mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
fix(render): honor proxy environment variables in Chrome launches
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(";")}`];
|
||||
}
|
||||
Reference in New Issue
Block a user