Fix authenticated HTTP proxies across all platforms

Route authenticated HTTP/HTTPS proxies through the browser's native proxy
authentication only on binaries that support it, resolved per platform and
binary version via a capability gate (sibling to the existing viewport and
window-geometry gates). Older binaries, including the free macOS and ARM
builds, fall back to the standard Playwright proxy path instead of emitting
credentials the binary cannot parse, so authenticated proxies keep working on
macOS and ARM instead of silently failing. Applied across the Python,
JavaScript, Puppeteer, and .NET wrappers.
This commit is contained in:
CloakHQ committed 2026-07-11 02:11:08 +02:00
1 parent 08415d9b26
commit 111b725e8c
15 files changed
+433 -202

No files matched your search

+2
View File
@@ -8,6 +8,8 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [Unreleased]
- **[wrapper]** Authenticated HTTP/HTTPS proxies now use the browser's native proxy authentication on every platform whose binary supports it — resolved per platform and binary version — and fall back to the standard proxy path on older binaries that don't. Fixes credentialed HTTP/HTTPS proxies on macOS and ARM, which previously could not use the native path. Python, JavaScript, Puppeteer, and .NET.
## [0.4.10] — 2026-07-09
- **[wrapper]** Fix JS CLI silently exiting with no output when run via `npx`/`node_modules/.bin` (#427). The entry-point guard compared `import.meta.url` to an unresolved `process.argv[1]`; symlinked bin installs (npm/pnpm/npx) never matched, so no subcommand ran. Now realpath-resolves the invoked path before comparing.
+18 -33
View File
@@ -24,9 +24,9 @@ from .config import (
DEFAULT_VIEWPORT,
IGNORE_DEFAULT_ARGS,
binary_supports_headless_no_viewport,
binary_supports_http_proxy_inline_auth,
binary_supports_maximized_window,
get_default_stealth_args,
normalize_requested_version,
)
from .download import ensure_binary
from .license import build_launch_env
@@ -206,7 +206,7 @@ def launch(
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
@@ -312,7 +312,7 @@ async def launch_async( # noqa: C901
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and not _suppress_maximize)
@@ -428,7 +428,7 @@ def launch_persistent_context(
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
@@ -565,7 +565,7 @@ async def launch_persistent_context_async(
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
@@ -1379,26 +1379,6 @@ def _normalize_http_string_url(url: str) -> str:
return result
_HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5"
_HTTP_PROXY_INLINE_AUTH_PLATFORMS = {"linux-x64", "windows-x64"}
def _supports_http_proxy_inline_auth(version: str | None = None) -> bool:
"""Check if the running binary supports HTTP proxy inline credentials.
Requires both a supported platform AND a binary version with preemptive proxy
auth. ``version`` is the pinned/resolved Chromium version actually being
launched; when None it falls back to the platform default. Passing the pin
matters because a rollback can run a binary older than the default (#182).
"""
from .config import get_platform_tag, get_chromium_version, _version_tuple
tag = get_platform_tag()
if tag not in _HTTP_PROXY_INLINE_AUTH_PLATFORMS:
return False
effective = version or get_chromium_version()
return _version_tuple(effective) >= _version_tuple(_HTTP_PROXY_INLINE_AUTH_MIN_VERSION)
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
"""Check if the proxy uses SOCKS5 protocol."""
if proxy is None:
@@ -1410,12 +1390,15 @@ def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
def _resolve_proxy_config(
proxy: str | ProxySettings | None,
browser_version: str | None = None,
license_key: str | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""Resolve proxy into Playwright kwargs and Chrome args.
Proxies with credentials (SOCKS5 or HTTP/HTTPS) are passed via Chrome's
--proxy-server flag with inline credentials, bypassing Playwright's CDP
auth interceptor which breaks on some proxies and Google domains (#182).
Proxies with credentials (SOCKS5 always; HTTP/HTTPS only on binaries that
support inline proxy auth) are passed via Chrome's --proxy-server flag with
inline credentials, bypassing Playwright's CDP auth interceptor which breaks
on some proxies and Google domains (#182). HTTP/HTTPS creds on older binaries
fall back to Playwright's proxy dict.
Returns:
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
@@ -1436,11 +1419,13 @@ def _resolve_proxy_config(
# passwords at '=' and other special chars (#157).
return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"]
# HTTP/HTTPS with credentials on supported platforms: use Chrome's native
# proxy authentication path instead of Playwright's CDP auth interceptor
# (#182).
requested_version = normalize_requested_version(browser_version)
if _has_credentials(proxy) and _supports_http_proxy_inline_auth(requested_version):
# HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth:
# use Chrome's native proxy authentication path instead of Playwright's CDP
# auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse
# inline credentials, so they fall through to the Playwright proxy dict below.
if _has_credentials(proxy) and binary_supports_http_proxy_inline_auth(
license_key, browser_version
):
if isinstance(proxy, dict):
url = _reconstruct_http_url(proxy)
extra_args = [f"--proxy-server={url}"]
+64
View File
@@ -344,6 +344,70 @@ def binary_supports_headless_no_viewport(
return False
# ---------------------------------------------------------------------------
# Inline HTTP proxy authentication by binary version
# ---------------------------------------------------------------------------
# First Chromium build, per platform, whose binary can take inline HTTP proxy
# credentials (Chromium-native ``--proxy-server=http://user:pass@host``). Below
# the floor the wrapper MUST route credentialed HTTP/HTTPS proxies through
# Playwright's proxy dict instead — an older binary treats the ``user:pass@``
# authority as an invalid proxy host and drops proxy auth (#182).
#
# A single global threshold (like HEADLESS_NO_VIEWPORT_MIN_VERSION) can't model
# this: the capability landed in different build lineages at different points —
# linux-x64/windows-x64 have it at 146.0.7680.177.5, but the free macOS (145.x)
# and linux-arm64 (146.0.7680.177.3) floors predate it, so those only qualify
# once they resolve to a Pro/newer build (148+). Platforms absent from this map
# are treated as never-inline (unknown capability => safe fallback).
HTTP_PROXY_INLINE_AUTH_MIN_VERSION: dict[str, str] = {
"linux-x64": "146.0.7680.177.5",
"windows-x64": "146.0.7680.177.5",
"linux-arm64": "148.0.7778.215.3",
"darwin-arm64": "148.0.7778.215.3",
"darwin-x64": "148.0.7778.215.3",
}
def binary_supports_http_proxy_inline_auth(
license_key: str | None = None, browser_version: str | None = None
) -> bool:
"""Whether the resolved binary accepts inline HTTP proxy credentials.
The capability is a function of the binary version; ``license_key`` only
resolves *which* version launches (Pro build vs free default), exactly like
``binary_supports_headless_no_viewport``. A declared pin wins, else a valid
Pro license resolves to the Pro build (which ships the patch), else the free
per-platform default — then it compares to this platform's floor. Below the
floor, credentialed HTTP proxies fall back to Playwright's proxy dict. A
local override with no declared version is unknown-version, so stay on the
safe fallback. Python, JS and .NET mirror this gate.
"""
floor = HTTP_PROXY_INLINE_AUTH_MIN_VERSION.get(get_platform_tag())
if floor is None:
return False
try:
declared = normalize_requested_version(browser_version)
except ValueError:
declared = None
if declared:
version = declared
elif get_local_binary_override():
return False
else:
from .license import resolve_license_key
pro = bool(resolve_license_key(license_key))
version = get_effective_version(pro=pro)
if version is None:
# Pro with no cached marker => resolve latest Pro from the server, which
# is >= the floor by construction and always ships the patch.
return True
try:
return not _version_newer(floor, version)
except (ValueError, AttributeError):
return False
def binary_supports_maximized_window(
license_key: str | None = None, browser_version: str | None = None
) -> bool:
+2 -2
View File
@@ -26,7 +26,7 @@ public static class CloakLauncher
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false);
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion);
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey);
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
args = MaybeAppendWebRtcExitIp(args, exitIp);
@@ -136,7 +136,7 @@ public static class CloakLauncher
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false);
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion);
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey);
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
args = MaybeAppendWebRtcExitIp(args, exitIp);
+77
View File
@@ -452,4 +452,81 @@ public static class Config
/// </summary>
public static bool BinarySupportsMaximizedWindow(string? licenseKey = null, string? browserVersion = null)
=> BinarySupportsHeadlessNoViewport(licenseKey, browserVersion);
// First Chromium build, per platform, whose binary can take inline HTTP proxy
// credentials (Chromium-native --proxy-server=http://user:pass@host). Below the
// floor the wrapper MUST route credentialed HTTP/HTTPS proxies through
// Playwright's proxy object — an older binary treats the user:pass@ authority as
// an invalid proxy host and drops proxy auth (#182). A single global threshold
// can't model this: the capability landed in different build lineages at different
// points (linux-x64/windows-x64 at 146.0.7680.177.5, but the free macOS 145.x and
// linux-arm64 146.0.7680.177.3 floors predate it, qualifying only once they resolve
// to a Pro/newer 148+ build). Platforms absent from this map are never-inline.
public static readonly IReadOnlyDictionary<string, string> HttpProxyInlineAuthMinVersion =
new Dictionary<string, string>
{
["linux-x64"] = "146.0.7680.177.5",
["windows-x64"] = "146.0.7680.177.5",
["linux-arm64"] = "148.0.7778.215.3",
["darwin-arm64"] = "148.0.7778.215.3",
["darwin-x64"] = "148.0.7778.215.3",
};
/// <summary>
/// Whether the resolved binary accepts inline HTTP proxy credentials. The
/// capability is a function of the binary version; <paramref name="licenseKey"/>
/// only resolves which version launches (Pro build vs free default), exactly like
/// <see cref="BinarySupportsHeadlessNoViewport"/>. A declared pin wins, else a valid
/// Pro license resolves to the Pro build (which ships the patch), else the free
/// per-platform default — then it compares to this platform's floor. Below the
/// floor, credentialed HTTP proxies fall back to Playwright's proxy object. A local
/// override with no declared version is unknown-version, so stay on the safe
/// fallback. Python, JS and .NET mirror this gate.
/// </summary>
public static bool BinarySupportsHttpProxyInlineAuth(string? licenseKey = null, string? browserVersion = null)
{
string tag;
try
{
tag = GetPlatformTag();
}
catch
{
return false;
}
if (!HttpProxyInlineAuthMinVersion.TryGetValue(tag, out var floor))
return false;
string? declared;
try
{
declared = NormalizeRequestedVersion(browserVersion);
}
catch
{
declared = null;
}
string? version;
if (!string.IsNullOrEmpty(declared))
{
version = declared!;
}
else if (GetLocalBinaryOverride() != null)
{
return false;
}
else
{
bool pro = !string.IsNullOrEmpty(License.ResolveLicenseKey(licenseKey));
version = GetEffectiveVersion(pro);
}
if (version == null) return false;
try
{
return !VersionNewer(floor, version);
}
catch
{
return false;
}
}
}
+5 -30
View File
@@ -7,10 +7,6 @@ namespace CloakBrowser;
/// </summary>
internal static class ProxyResolver
{
private const string HttpProxyInlineAuthMinVersion = "146.0.7680.177.5";
private static readonly IReadOnlySet<string> HttpProxyInlineAuthPlatforms =
new HashSet<string> { "linux-x64", "windows-x64" };
/// <summary>Result of resolving a proxy: Playwright proxy (or null) plus extra Chrome args.</summary>
public sealed record ProxyResolution(Microsoft.Playwright.Proxy? PlaywrightProxy, List<string> ExtraArgs);
@@ -267,28 +263,6 @@ internal static class ProxyResolver
return result;
}
private static bool SupportsHttpProxyInlineAuth(string? version = null)
{
string tag = Config.GetPlatformTag();
if (!HttpProxyInlineAuthPlatforms.Contains(tag))
return false;
return Compare(version ?? Config.GetChromiumVersion(), HttpProxyInlineAuthMinVersion) >= 0;
}
private static int Compare(string a, string b)
{
var ta = Config.VersionTuple(a);
var tb = Config.VersionTuple(b);
int n = Math.Max(ta.Length, tb.Length);
for (int i = 0; i < n; i++)
{
int va = i < ta.Length ? ta[i] : 0;
int vb = i < tb.Length ? tb[i] : 0;
if (va != vb) return va.CompareTo(vb);
}
return 0;
}
/// <summary>Extract a normalized proxy URL string from a string or dict proxy (for geoip / webrtc).</summary>
public static string? ExtractProxyUrl(object? proxy)
{
@@ -307,7 +281,7 @@ internal static class ProxyResolver
}
/// <summary>Resolve a proxy into Playwright options + extra Chrome args (one or both empty).</summary>
public static ProxyResolution Resolve(object? proxy, string? browserVersion = null)
public static ProxyResolution Resolve(object? proxy, string? browserVersion = null, string? licenseKey = null)
{
if (proxy == null)
return new ProxyResolution(null, new List<string>());
@@ -333,15 +307,16 @@ internal static class ProxyResolver
return new ProxyResolution(null, new List<string> { $"--proxy-server={NormalizeSocksStringUrl(sUrl)}" });
}
// HTTP/HTTPS with credentials on supported platforms: inline creds via --proxy-server.
// HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth:
// inline creds via --proxy-server. Older binaries (free macOS/linux-arm64)
// can't parse inline credentials, so they fall through to Playwright's proxy.
bool hasCreds = proxy switch
{
ProxySettings ps => HasCredentials(ps),
string s => HasCredentials(s),
_ => false,
};
var requestedVersion = Config.NormalizeRequestedVersion(browserVersion);
if (hasCreds && SupportsHttpProxyInlineAuth(requestedVersion))
if (hasCreds && Config.BinarySupportsHttpProxyInlineAuth(licenseKey, browserVersion))
{
if (proxy is ProxySettings psd)
{
@@ -66,21 +66,24 @@ public class ProxyResolverTests
}
[Fact]
public void Http_With_Creds_Parses_Into_PlaywrightFields_When_InlineAuth_Unsupported()
public void Http_With_Creds_Uses_InlineAuth_On_Binary_With_Patch()
{
// On platforms NOT in the inline-auth set (e.g. linux-arm64, darwin-*),
// HTTP creds go to the Playwright proxy dict. On linux-x64/windows-x64 with
// a recent binary they go to --proxy-server. Accept either valid outcome.
var r = ProxyResolver.Resolve("http://user:pass@host:8080");
bool inlineArg = r.ExtraArgs.Any(a => a.StartsWith("--proxy-server="));
bool pwProxy = r.PlaywrightProxy != null;
Assert.True(inlineArg ^ pwProxy, "Exactly one of inline-arg or playwright-proxy should be set");
if (pwProxy)
{
Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server);
Assert.Equal("user", r.PlaywrightProxy.Username);
Assert.Equal("pass", r.PlaywrightProxy.Password);
}
// Pin a version at/above every platform floor → inline on any host.
var r = ProxyResolver.Resolve("http://user:pass@host:8080", "148.0.7778.215.3");
Assert.Null(r.PlaywrightProxy);
Assert.Equal("--proxy-server=http://user:pass@host:8080", Assert.Single(r.ExtraArgs));
}
[Fact]
public void Http_With_Creds_Falls_Back_On_Binary_Without_Patch()
{
// Pin a version below every platform floor → Playwright proxy on any host.
var r = ProxyResolver.Resolve("http://user:pass@host:8080", "146.0.7680.177.3");
Assert.Empty(r.ExtraArgs);
Assert.NotNull(r.PlaywrightProxy);
Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server);
Assert.Equal("user", r.PlaywrightProxy.Username);
Assert.Equal("pass", r.PlaywrightProxy.Password);
}
[Fact]
+68
View File
@@ -320,6 +320,74 @@ export function binarySupportsMaximizedWindow(
return binarySupportsHeadlessNoViewport(licenseKey, browserVersion);
}
// ---------------------------------------------------------------------------
// Inline HTTP proxy authentication by binary version
// ---------------------------------------------------------------------------
// First Chromium build, per platform, whose binary can take inline HTTP proxy
// credentials (Chromium-native `--proxy-server=http://user:pass@host`). Below
// the floor the wrapper MUST route credentialed HTTP/HTTPS proxies through
// Playwright's proxy dict / page.authenticate — an older binary treats the
// `user:pass@` authority as an invalid proxy host and drops proxy auth (#182).
// A single global threshold can't model this: the capability landed in
// different build lineages at different points (linux-x64/windows-x64 at
// 146.0.7680.177.5, but the free macOS 145.x and linux-arm64 146.0.7680.177.3
// floors predate it, qualifying only once they resolve to a Pro/newer 148+
// build). Platforms absent from this map are treated as never-inline.
export const HTTP_PROXY_INLINE_AUTH_MIN_VERSION: Record<string, string> = {
"linux-x64": "146.0.7680.177.5",
"windows-x64": "146.0.7680.177.5",
"linux-arm64": "148.0.7778.215.3",
"darwin-arm64": "148.0.7778.215.3",
"darwin-x64": "148.0.7778.215.3",
};
/**
* Whether the resolved binary accepts inline HTTP proxy credentials. The
* capability is a function of the binary version; `licenseKey` only resolves
* which version launches (Pro build vs free default), exactly like
* binarySupportsHeadlessNoViewport. A declared pin wins, else a valid Pro
* license resolves to the Pro build (which ships the patch), else the free
* per-platform default — then it compares to this platform's floor. Below the
* floor, credentialed HTTP proxies fall back to Playwright's proxy dict. A
* local override with no declared version is unknown-version, so fail safe.
* Python, JS and .NET mirror this gate.
*/
export function binarySupportsHttpProxyInlineAuth(
licenseKey?: string,
browserVersion?: string,
): boolean {
let tag: string;
try {
tag = getPlatformTag();
} catch {
return false;
}
const floor = HTTP_PROXY_INLINE_AUTH_MIN_VERSION[tag];
if (!floor) return false;
let declared: string | undefined;
try {
declared = normalizeRequestedVersion(browserVersion);
} catch {
declared = undefined;
}
let version: string | null;
if (declared) {
version = declared;
} else if (getLocalBinaryOverride()) {
return false;
} else {
const pro = Boolean(resolveLicenseKey(licenseKey));
version = getEffectiveVersion(pro);
}
if (version === null) return false;
try {
if (parseVersion(version).some(Number.isNaN)) return false;
return !versionNewer(floor, version);
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Playwright default args to suppress — these leak automation signals.
// --enable-automation: exposes navigator.webdriver = true
+2 -2
View File
@@ -114,7 +114,7 @@ export async function buildLaunchOptions(
process.env.CLOAKBROWSER_BINARY_PATH ||
(await ensureBinary(options.licenseKey, options.browserVersion));
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion, options.licenseKey);
let resolvedArgs = await resolveWebrtcArgs(options);
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
@@ -296,7 +296,7 @@ export async function launchPersistentContext(
process.env.CLOAKBROWSER_BINARY_PATH ||
(await ensureBinary(options.licenseKey, options.browserVersion));
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion, options.licenseKey);
let resolvedArgs = await resolveWebrtcArgs(options);
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
+9 -27
View File
@@ -2,7 +2,7 @@
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
*/
import { getChromiumVersion, getPlatformTag, normalizeRequestedVersion, parseVersion } from "./config.js";
import { binarySupportsHttpProxyInlineAuth } from "./config.js";
export interface ParsedProxy {
server: string;
@@ -74,7 +74,7 @@ function assembleSocksUrl(
* bare ``%`` not followed by two hex digits is left as a literal ``%``.
*/
function lenientDecodeURIComponent(s: string): string {
return s.replace(/%([0-9A-Fa-f]{2})|%/g, (match, hex) =>
return s.replace(/%([0-9A-Fa-f]{2})|%/g, (_match, hex) =>
hex ? String.fromCharCode(parseInt(hex, 16)) : "%",
);
}
@@ -157,25 +157,6 @@ 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(version?: string): boolean {
try {
const tag = getPlatformTag();
if (!HTTP_PROXY_INLINE_AUTH_PLATFORMS.has(tag)) return false;
const current = parseVersion(version ?? 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;
@@ -260,7 +241,8 @@ export function normalizeHttpStringUrl(urlStr: string): string {
*/
export function resolveProxyConfig(
proxy: string | ProxyDict | undefined,
browserVersion?: string
browserVersion?: string,
licenseKey?: string
): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
@@ -277,11 +259,11 @@ export function resolveProxyConfig(
return { proxyArgs: args };
}
// HTTP/HTTPS with credentials on supported platforms: use Chrome's native
// proxy authentication path instead of Playwright's CDP auth interceptor
// (#182).
const requestedVersion = normalizeRequestedVersion(browserVersion);
if (hasCredentials(proxy) && supportsHttpProxyInlineAuth(requestedVersion)) {
// HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth:
// use Chrome's native proxy authentication path instead of Playwright's CDP
// auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse
// inline credentials, so they fall through to the Playwright proxy dict below.
if (hasCredentials(proxy) && binarySupportsHttpProxyInlineAuth(licenseKey, browserVersion)) {
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
}
+9 -7
View File
@@ -10,11 +10,12 @@ import {
DEFAULT_VIEWPORT,
IGNORE_DEFAULT_ARGS,
binarySupportsHeadlessNoViewport,
binarySupportsHttpProxyInlineAuth,
} from "./config.js";
import { buildArgs } from "./args.js";
import { maybeWarnWindowsFonts } from "./fonts.js";
import { ensureBinary } from "./download.js";
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs, appendWebrtcExitIp } from "./geoip.js";
import { buildLaunchEnv } from "./license.js";
import { seedWidevineHint } from "./widevine.js";
@@ -60,8 +61,9 @@ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string
/**
* Resolve proxy into Chrome CLI args and optional HTTP auth credentials.
* 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.
* HTTP on binaries with inline proxy auth: inline credentials via --proxy-server.
* HTTP on older binaries (free macOS/linux-arm64): strip credentials, use
* page.authenticate() fallback.
*/
function resolveProxy(options: LaunchOptions, args: string[]): { username: string; password: string } | undefined {
if (!options.proxy) return undefined;
@@ -72,8 +74,8 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
return undefined;
}
// On supported platforms: pass full URL with inline creds to --proxy-server
if (supportsHttpProxyInlineAuth()) {
// On binaries that ship inline proxy auth: pass full URL with inline creds.
if (binarySupportsHttpProxyInlineAuth(options.licenseKey, options.browserVersion)) {
if (typeof options.proxy === "string") {
args.push(`--proxy-server=${normalizeHttpStringUrl(options.proxy)}`);
return undefined;
@@ -88,7 +90,7 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
return undefined;
}
// Unsupported platform: strip credentials, fall back to page.authenticate()
// Older binary: strip credentials, fall back to page.authenticate()
if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
@@ -105,7 +107,7 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
return username ? { username, password: password ?? "" } : undefined;
}
/** Apply proxy auth fallback (unsupported platforms) and humanize patching. */
/** Apply proxy auth fallback (older binaries) and humanize patching. */
async function applyPostLaunch(
browser: Browser,
options: LaunchOptions,
+27 -7
View File
@@ -149,8 +149,14 @@ describe("composable Playwright launch helpers", () => {
});
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
// Free macOS lacks inline proxy auth → credentialed HTTP proxy goes through
// Playwright's proxy dict. Drive the platform via process.platform/arch since
// getPlatformTag reads them at call time (a config spy can't reach the
// intra-config gate call).
const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
const origArch = Object.getOwnPropertyDescriptor(process, "arch")!;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
try {
const { buildLaunchOptions } = await import("../src/index.js");
@@ -170,10 +176,15 @@ describe("composable Playwright launch helpers", () => {
username: "user",
password: "pass",
});
expect(options.args).not.toContain(
"--proxy-server=http://user:pass@proxy.example:8080",
);
expect(options.timeout).toBe(1234);
// No license key -> env is not injected
expect(options.env).toBeUndefined();
} finally {
Object.defineProperty(process, "platform", origPlatform);
Object.defineProperty(process, "arch", origArch);
vi.restoreAllMocks();
}
});
@@ -449,8 +460,12 @@ describe("launchPersistentContext (unit)", () => {
});
it("forwards proxy string", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
// Free macOS → credentialed HTTP proxy via Playwright's proxy dict, not
// inline --proxy-server. Drive platform via process.platform/arch.
const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
const origArch = Object.getOwnPropertyDescriptor(process, "arch")!;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
try {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
@@ -459,10 +474,15 @@ describe("launchPersistentContext (unit)", () => {
});
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");
expect(args.proxy).toEqual({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
expect(args.args).not.toContain("--proxy-server=http://user:pass@proxy:8080");
} finally {
Object.defineProperty(process, "platform", origPlatform);
Object.defineProperty(process, "arch", origArch);
vi.restoreAllMocks();
}
});
+82 -49
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig, reconstructHttpUrl, normalizeHttpStringUrl } from "../src/proxy.js";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import * as config from "../src/config.js";
import type { LaunchOptions } from "../src/types.js";
@@ -148,20 +148,35 @@ describe("isSocksProxy", () => {
});
describe("resolveProxyConfig", () => {
// binarySupportsHttpProxyInlineAuth reads getPlatformTag() intra-config, which
// a vi.spyOn(config, ...) can't intercept (ESM live binding). getPlatformTag
// derives from process.platform/arch at call time, so override those instead —
// this correctly drives the per-platform floor lookup and version resolution.
const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
const origArch = Object.getOwnPropertyDescriptor(process, "arch")!;
function setPlatform(platform: string, arch: string): void {
Object.defineProperty(process, "platform", { value: platform, configurable: true });
Object.defineProperty(process, "arch", { value: arch, configurable: true });
}
function restorePlatform(): void {
Object.defineProperty(process, "platform", origPlatform);
Object.defineProperty(process, "arch", origArch);
}
it("returns empty for undefined", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http string on unsupported platform", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
it("falls back to playwright dict for http string on free macOS", () => {
setPlatform("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();
restorePlatform();
}
});
@@ -330,93 +345,111 @@ describe("resolveProxyConfig", () => {
// --- HTTP with credentials → --proxy-server (supported platform + version) ---
// Supported cases pass the version via the browserVersion param (declared-pin
// path) so resolution is deterministic; setPlatform drives the floor lookup.
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");
setPlatform("linux", "x64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
const { proxyOption, proxyArgs } = resolveProxyConfig(
"http://user:pass@proxy:8080",
"146.0.7680.177.5",
);
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
restorePlatform();
}
});
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");
setPlatform("linux", "x64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
const { proxyOption, proxyArgs } = resolveProxyConfig(
{
server: "http://proxy:8080",
username: "user",
password: "pass",
},
"146.0.7680.177.5",
);
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
restorePlatform();
}
});
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");
setPlatform("win32", "x64");
try {
const { proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".google.com",
});
const { proxyArgs } = resolveProxyConfig(
{
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".google.com",
},
"146.0.7680.177.5",
);
expect(proxyArgs).toContain("--proxy-server=http://user:pass@proxy:8080");
expect(proxyArgs).toContain("--proxy-bypass-list=.google.com");
} finally {
vi.restoreAllMocks();
restorePlatform();
}
});
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");
setPlatform("linux", "x64");
try {
const { proxyArgs } = resolveProxyConfig("http://user:pass=123@proxy:8080");
const { proxyArgs } = resolveProxyConfig(
"http://user:pass=123@proxy:8080",
"146.0.7680.177.5",
);
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass%3D123@proxy:8080"]);
} finally {
vi.restoreAllMocks();
restorePlatform();
}
});
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");
it("falls back to playwright dict with an old pinned version", () => {
setPlatform("linux", "x64");
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");
const { proxyOption, proxyArgs } = resolveProxyConfig(
"http://user:pass@proxy:8080",
"146.0.7680.177.3",
);
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
restorePlatform();
}
});
it("falls back to playwright dict for http with creds on linux-arm64", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-arm64");
it("inline auth for http with creds on a new pinned darwin-arm64 build", () => {
setPlatform("darwin", "arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
const { proxyOption, proxyArgs } = resolveProxyConfig(
"http://user:pass@proxy:8080",
"148.0.7778.215.3",
);
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
restorePlatform();
}
});
it("falls back to playwright dict for http with creds on free linux-arm64", () => {
setPlatform("linux", "arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig(
"http://user:pass@proxy:8080",
"146.0.7680.177.3",
);
expect(proxyOption).toBeDefined();
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
restorePlatform();
}
});
});
+32 -18
View File
@@ -132,30 +132,39 @@ describe("puppeteer launch", () => {
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
});
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");
it("uses page.authenticate fallback for http proxy on free macOS", async () => {
// Free macOS lacks inline proxy auth → strip creds, use page.authenticate.
// getPlatformTag reads process.platform/arch at call time.
const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
const origArch = Object.getOwnPropertyDescriptor(process, "arch")!;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
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://proxy:8080");
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
expect(page.authenticate).toHaveBeenCalledWith({ username: "user", password: "pass" });
} finally {
Object.defineProperty(process, "platform", origPlatform);
Object.defineProperty(process, "arch", origArch);
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");
const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
const origArch = Object.getOwnPropertyDescriptor(process, "arch")!;
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
try {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
const browser = await launch({
proxy: "http://user:pass@proxy:8080",
browserVersion: "146.0.7680.177.5",
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://user:pass@proxy:8080");
@@ -163,6 +172,8 @@ describe("puppeteer launch", () => {
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
} finally {
Object.defineProperty(process, "platform", origPlatform);
Object.defineProperty(process, "arch", origArch);
vi.restoreAllMocks();
}
});
@@ -302,9 +313,11 @@ describe("puppeteer launchPersistentContext", () => {
).toEqual(DEFAULT_VIEWPORT);
});
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");
it("uses page.authenticate fallback in persistent context on free macOS", async () => {
const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
const origArch = Object.getOwnPropertyDescriptor(process, "arch")!;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
try {
const { launchPersistentContext } = await import("../src/puppeteer.js");
const browser = await launchPersistentContext({
@@ -312,12 +325,13 @@ describe("puppeteer launchPersistentContext", () => {
proxy: "http://user:pass@proxy:8080",
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://proxy:8080");
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
expect(page.authenticate).toHaveBeenCalledWith({ username: "user", password: "pass" });
} finally {
Object.defineProperty(process, "platform", origPlatform);
Object.defineProperty(process, "arch", origArch);
vi.restoreAllMocks();
}
});
+19 -13
View File
@@ -68,12 +68,12 @@ class TestBuildProxyKwargs:
assert kwargs == {"proxy": proxy_dict}
assert args == []
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_pinned_old_version_disables_inline_auth(self, *_):
# Pin a binary BELOW the inline-auth floor while the platform default is
# at/above it. The gate must read the pin (older = no preemptive auth),
# not the default — otherwise rolled-back binaries break proxy auth (#182).
def test_pinned_old_version_disables_inline_auth(self, _mock):
# Pin a binary BELOW the inline-auth floor (146.0.7680.177.5) on a
# platform that otherwise supports it. The gate must read the pin — an
# older binary lacks inline proxy auth — and fall back to Playwright's
# dict, else rolled-back binaries break proxy auth (#182).
kwargs, args = _resolve_proxy_config(
"http://user:pass@proxy:8080", browser_version="146.0.7680.177.3"
)
@@ -465,14 +465,10 @@ class TestResolveProxyConfig:
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.3")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_with_creds_old_version_falls_back(self, *_):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert args == []
# --- HTTP with credentials on unsupported platform → fallback to Playwright ---
# --- HTTP with credentials on binaries without inline proxy auth → fallback ---
# Free macOS (145.x) and linux-arm64 (146.0.7680.177.3) predate the inline
# proxy-auth patch, so their credentialed HTTP proxies route through
# Playwright's proxy dict instead of --proxy-server.
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
def test_http_string_with_creds_on_macos_falls_back(self, _mock):
@@ -494,6 +490,16 @@ class TestResolveProxyConfig:
assert "proxy" in kwargs
assert args == []
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
def test_http_with_creds_on_macos_inline_when_pinned_new(self, _mock):
# A macOS binary at/above the floor (e.g. a pinned 148 build with the
# inline proxy-auth patch) uses --proxy-server, not the fallback.
kwargs, args = _resolve_proxy_config(
"http://user:pass@proxy:8080", browser_version="148.0.7778.215.3"
)
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
# --- HTTP without credentials (all platforms) ---
def test_http_no_creds_returns_playwright_dict(self):