feat: route HTTP proxy credentials through --proxy-server

Bypass Playwright's CDP Fetch.authRequired interceptor for authenticated
HTTP proxies by passing inline credentials via Chrome's --proxy-server
flag. Chrome sends Proxy-Authorization preemptively, avoiding the 407
round-trip that breaks on some proxies and Google domains (#182).

Gated on platform (linux-x64, windows-x64) and binary version >= 146.0.7680.177.5.
Unsupported platforms fall back to Playwright's proxy dict.
Puppeteer falls back to page.authenticate() on unsupported platforms.
This commit is contained in:
CloakHQ
2026-05-21 05:51:03 +02:00
parent 864cae2493
commit 8028ddefef
8 changed files with 542 additions and 82 deletions
+112 -3
View File
@@ -2,6 +2,8 @@
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
*/
import { getChromiumVersion, getPlatformTag, parseVersion } from "./config.js";
export interface ParsedProxy {
server: string;
username?: string;
@@ -155,11 +157,106 @@ export function normalizeSocksStringUrl(urlStr: string): string {
}
}
const HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5";
const HTTP_PROXY_INLINE_AUTH_PLATFORMS = new Set(["linux-x64", "windows-x64"]);
export function supportsHttpProxyInlineAuth(): boolean {
try {
const tag = getPlatformTag();
if (!HTTP_PROXY_INLINE_AUTH_PLATFORMS.has(tag)) return false;
const current = parseVersion(getChromiumVersion());
const minimum = parseVersion(HTTP_PROXY_INLINE_AUTH_MIN_VERSION);
for (let i = 0; i < Math.max(current.length, minimum.length); i++) {
if ((current[i] ?? 0) > (minimum[i] ?? 0)) return true;
if ((current[i] ?? 0) < (minimum[i] ?? 0)) return false;
}
return true; // equal = supported
} catch {
return false;
}
}
function hasCredentials(proxy: string | ProxyDict): boolean {
if (typeof proxy === "string") return proxy.includes("@");
return !!proxy.username;
}
/**
* Reconstruct an HTTP(S) proxy URL with inline credentials from a proxy dict.
*/
export function reconstructHttpUrl(proxy: ProxyDict): string {
if (!proxy.username) return proxy.server;
const url = new URL(ensureProxyScheme(proxy.server));
url.username = encodeURIComponent(proxy.username);
if (proxy.password) url.password = encodeURIComponent(proxy.password);
return url.href.replace(/\/$/, "");
}
/**
* Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
* Same pattern as normalizeSocksStringUrl.
*/
export function normalizeHttpStringUrl(urlStr: string): string {
const normalized = urlStr.includes("://") ? urlStr : `http://${urlStr}`;
const schemeMatch = normalized.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
if (!schemeMatch) return normalized;
const [, scheme, rest] = schemeMatch;
const hostStart = rest.search(/[/?#]/);
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
const atIdx = authority.lastIndexOf("@");
if (atIdx === -1) return normalized;
const userinfo = authority.slice(0, atIdx);
const hostPart = authority.slice(atIdx + 1);
const bracketEnd = hostPart.lastIndexOf("]");
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
if (portColonIdx !== -1) {
const portStr = hostPart.slice(portColonIdx + 1);
if (portStr && !/^\d+$/.test(portStr)) {
console.warn(`[cloakbrowser] Malformed HTTP proxy URL, passing through unchanged: invalid port`);
return normalized;
}
}
const hostAndRest = hostPart + suffix;
const colonIdx = userinfo.indexOf(":");
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
const hasPassword = colonIdx !== -1;
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
try {
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
const encPass = hasPassword
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
: null;
let userinfoPart: string;
if (encPass !== null) {
userinfoPart = `${encUser}:${encPass}@`;
} else if (encUser) {
userinfoPart = `${encUser}@`;
} else {
userinfoPart = "";
}
const result = `${scheme}://${userinfoPart}${hostAndRest}`;
const credsChanged = encUser !== rawUserEnc
|| (hasPassword ? encPass !== rawPassEnc : false);
if (credsChanged) {
console.info(
"[cloakbrowser] Auto URL-encoded HTTP proxy credentials (special " +
"characters detected). Pre-encode the URL to suppress this notice.",
);
}
return result;
} catch (e) {
console.warn(`[cloakbrowser] Could not normalize HTTP proxy URL, passing through unchanged: ${(e as Error).message}`);
return normalized;
}
}
/**
* Resolve proxy into Playwright option and/or Chrome args.
*
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
* Proxies with credentials (SOCKS5 or HTTP/HTTPS on supported platforms) are
* passed via Chrome's --proxy-server flag with inline credentials, bypassing
* Playwright's CDP auth interceptor which breaks on some proxies (#182).
*/
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
@@ -177,7 +274,19 @@ export function resolveProxyConfig(proxy: string | ProxyDict | undefined): Proxy
return { proxyArgs: args };
}
// HTTP/HTTPS: use Playwright's proxy dict
// HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
// CDP auth interceptor, use Chrome's preemptive Proxy-Authorization (#182).
if (hasCredentials(proxy) && supportsHttpProxyInlineAuth()) {
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
}
const httpUrl = reconstructHttpUrl(proxy);
const args = [`--proxy-server=${httpUrl}`];
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
return { proxyArgs: args };
}
// HTTP/HTTPS without credentials (or unsupported platform): use Playwright's proxy dict
if (typeof proxy === "string") {
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
}
+22 -5
View File
@@ -9,7 +9,7 @@ import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
@@ -26,9 +26,9 @@ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string
/**
* Resolve proxy into Chrome CLI args and optional HTTP auth credentials.
* SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
* HTTP: Chrome does NOT support inline credentials — strip them and
* use page.authenticate() for Proxy-Authorization headers instead.
* SOCKS5: Chrome handles inline credentials natively (RFC 1929 auth).
* HTTP on supported platforms: inline credentials via --proxy-server.
* HTTP on unsupported platforms: strip credentials, use page.authenticate() fallback.
*/
function resolveProxy(options: LaunchOptions, args: string[]): { username: string; password: string } | undefined {
if (!options.proxy) return undefined;
@@ -39,6 +39,23 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
return undefined;
}
// On supported platforms: pass full URL with inline creds to --proxy-server
if (supportsHttpProxyInlineAuth()) {
if (typeof options.proxy === "string") {
args.push(`--proxy-server=${normalizeHttpStringUrl(options.proxy)}`);
return undefined;
}
const url = options.proxy.username
? reconstructHttpUrl(options.proxy)
: options.proxy.server;
args.push(`--proxy-server=${url}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
return undefined;
}
// Unsupported platform: strip credentials, fall back to page.authenticate()
if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
@@ -55,7 +72,7 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
return username ? { username, password: password ?? "" } : undefined;
}
/** Apply proxy auth monkey-patch and humanize behavioral patching. */
/** Apply proxy auth fallback (unsupported platforms) and humanize patching. */
async function applyPostLaunch(
browser: Browser,
options: LaunchOptions,
+39 -26
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { binaryInfo } from "../src/download.js";
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
import * as config from "../src/config.js";
describe("binaryInfo", () => {
it("returns correct structure", () => {
@@ -47,25 +48,31 @@ describe("composable Playwright launch helpers", () => {
});
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
const { buildLaunchOptions } = await import("../src/index.js");
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { buildLaunchOptions } = await import("../src/index.js");
const options = await buildLaunchOptions({
headless: false,
proxy: "http://user:pass@proxy.example:8080",
args: ["--custom-flag"],
launchOptions: { timeout: 1234 },
});
const options = await buildLaunchOptions({
headless: false,
proxy: "http://user:pass@proxy.example:8080",
args: ["--custom-flag"],
launchOptions: { timeout: 1234 },
});
expect(options.executablePath).toBe("/fake/chrome");
expect(options.headless).toBe(false);
expect(options.args).toContain("--custom-flag");
expect(options.ignoreDefaultArgs).toContain("--enable-automation");
expect(options.proxy).toEqual({
server: "http://proxy.example:8080",
username: "user",
password: "pass",
});
expect(options.timeout).toBe(1234);
expect(options.executablePath).toBe("/fake/chrome");
expect(options.headless).toBe(false);
expect(options.args).toContain("--custom-flag");
expect(options.ignoreDefaultArgs).toContain("--enable-automation");
expect(options.proxy).toEqual({
server: "http://proxy.example:8080",
username: "user",
password: "pass",
});
expect(options.timeout).toBe(1234);
} finally {
vi.restoreAllMocks();
}
});
it("humanizeBrowser patches an existing browser only when requested", async () => {
@@ -307,16 +314,22 @@ describe("launchPersistentContext (unit)", () => {
});
it("forwards proxy string", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
proxy: "http://user:pass@proxy:8080",
});
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
proxy: "http://user:pass@proxy:8080",
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.proxy.server).toBe("http://proxy:8080");
expect(args.proxy.username).toBe("user");
expect(args.proxy.password).toBe("pass");
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.proxy.server).toBe("http://proxy:8080");
expect(args.proxy.username).toBe("user");
expect(args.proxy.password).toBe("pass");
} finally {
vi.restoreAllMocks();
}
});
it("forwards userAgent and colorScheme", async () => {
+103 -5
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig, reconstructHttpUrl, normalizeHttpStringUrl } from "../src/proxy.js";
import * as config from "../src/config.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => {
@@ -153,10 +154,15 @@ describe("resolveProxyConfig", () => {
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
it("returns playwright dict for http string on unsupported platform", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("returns playwright dict for http dict", () => {
@@ -321,4 +327,96 @@ describe("resolveProxyConfig", () => {
debugSpy.mockRestore();
}
});
// --- HTTP with credentials → --proxy-server (supported platform + version) ---
it("routes http string with creds through --proxy-server on linux-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("routes http dict with creds through --proxy-server on linux-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("includes bypass for http dict with creds on windows-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("windows-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".google.com",
});
expect(proxyArgs).toContain("--proxy-server=http://user:pass@proxy:8080");
expect(proxyArgs).toContain("--proxy-bypass-list=.google.com");
} finally {
vi.restoreAllMocks();
}
});
it("encodes special chars in http proxy password on supported platform v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyArgs } = resolveProxyConfig("http://user:pass=123@proxy:8080");
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass%3D123@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back on linux-x64 with old version (pre-inline-auth)", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.3");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeDefined();
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back to playwright dict for http with creds on darwin-arm64", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back to playwright dict for http with creds on linux-arm64", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeDefined();
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
});
+49 -20
View File
@@ -84,16 +84,39 @@ describe("puppeteer launch", () => {
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
});
it("monkey-patches newPage for proxy auth", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
it("uses page.authenticate fallback for http proxy on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
// newPage should auto-authenticate
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
} finally {
vi.restoreAllMocks();
}
});
it("passes inline creds via --proxy-server on supported platform (no page.authenticate)", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://user:pass@proxy:8080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
}
});
it("injects timezone and locale as binary flags", async () => {
@@ -189,18 +212,24 @@ describe("puppeteer launchPersistentContext", () => {
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
});
it("handles proxy auth with persistent context", async () => {
const { launchPersistentContext } = await import("../src/puppeteer.js");
const browser = await launchPersistentContext({
userDataDir: "./my-profile",
proxy: "http://user:pass@proxy:8080",
});
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launchPersistentContext } = await import("../src/puppeteer.js");
const browser = await launchPersistentContext({
userDataDir: "./my-profile",
proxy: "http://user:pass@proxy:8080",
});
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
} finally {
vi.restoreAllMocks();
}
});
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {