Skip to content

Commit 111b725

Browse files
committed
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.
1 parent 08415d9 commit 111b725

15 files changed

Lines changed: 429 additions & 198 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
88

99
## [Unreleased]
1010

11+
- **[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.
12+
1113
## [0.4.10] — 2026-07-09
1214

1315
- **[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.

cloakbrowser/browser.py

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424
DEFAULT_VIEWPORT,
2525
IGNORE_DEFAULT_ARGS,
2626
binary_supports_headless_no_viewport,
27+
binary_supports_http_proxy_inline_auth,
2728
binary_supports_maximized_window,
2829
get_default_stealth_args,
29-
normalize_requested_version,
3030
)
3131
from .download import ensure_binary
3232
from .license import build_launch_env
@@ -206,7 +206,7 @@ def launch(
206206

207207
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
208208
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
209-
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
209+
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
210210
args = _resolve_webrtc_args(args, proxy)
211211
args = _append_webrtc_exit_ip(args, exit_ip)
212212

@@ -312,7 +312,7 @@ async def launch_async( # noqa: C901
312312

313313
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
314314
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
315-
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
315+
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
316316
args = _resolve_webrtc_args(args, proxy)
317317
args = _append_webrtc_exit_ip(args, exit_ip)
318318
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(
428428

429429
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
430430
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
431-
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
431+
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
432432
args = _resolve_webrtc_args(args, proxy)
433433
args = _append_webrtc_exit_ip(args, exit_ip)
434434
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(
565565

566566
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
567567
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
568-
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
568+
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
569569
args = _resolve_webrtc_args(args, proxy)
570570
args = _append_webrtc_exit_ip(args, exit_ip)
571571
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:
13791379
return result
13801380

13811381

1382-
_HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5"
1383-
_HTTP_PROXY_INLINE_AUTH_PLATFORMS = {"linux-x64", "windows-x64"}
1384-
1385-
1386-
def _supports_http_proxy_inline_auth(version: str | None = None) -> bool:
1387-
"""Check if the running binary supports HTTP proxy inline credentials.
1388-
1389-
Requires both a supported platform AND a binary version with preemptive proxy
1390-
auth. ``version`` is the pinned/resolved Chromium version actually being
1391-
launched; when None it falls back to the platform default. Passing the pin
1392-
matters because a rollback can run a binary older than the default (#182).
1393-
"""
1394-
from .config import get_platform_tag, get_chromium_version, _version_tuple
1395-
tag = get_platform_tag()
1396-
if tag not in _HTTP_PROXY_INLINE_AUTH_PLATFORMS:
1397-
return False
1398-
effective = version or get_chromium_version()
1399-
return _version_tuple(effective) >= _version_tuple(_HTTP_PROXY_INLINE_AUTH_MIN_VERSION)
1400-
1401-
14021382
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
14031383
"""Check if the proxy uses SOCKS5 protocol."""
14041384
if proxy is None:
@@ -1410,12 +1390,15 @@ def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
14101390
def _resolve_proxy_config(
14111391
proxy: str | ProxySettings | None,
14121392
browser_version: str | None = None,
1393+
license_key: str | None = None,
14131394
) -> tuple[dict[str, Any], list[str]]:
14141395
"""Resolve proxy into Playwright kwargs and Chrome args.
14151396
1416-
Proxies with credentials (SOCKS5 or HTTP/HTTPS) are passed via Chrome's
1417-
--proxy-server flag with inline credentials, bypassing Playwright's CDP
1418-
auth interceptor which breaks on some proxies and Google domains (#182).
1397+
Proxies with credentials (SOCKS5 always; HTTP/HTTPS only on binaries that
1398+
support inline proxy auth) are passed via Chrome's --proxy-server flag with
1399+
inline credentials, bypassing Playwright's CDP auth interceptor which breaks
1400+
on some proxies and Google domains (#182). HTTP/HTTPS creds on older binaries
1401+
fall back to Playwright's proxy dict.
14191402
14201403
Returns:
14211404
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
@@ -1436,11 +1419,13 @@ def _resolve_proxy_config(
14361419
# passwords at '=' and other special chars (#157).
14371420
return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"]
14381421

1439-
# HTTP/HTTPS with credentials on supported platforms: use Chrome's native
1440-
# proxy authentication path instead of Playwright's CDP auth interceptor
1441-
# (#182).
1442-
requested_version = normalize_requested_version(browser_version)
1443-
if _has_credentials(proxy) and _supports_http_proxy_inline_auth(requested_version):
1422+
# HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth:
1423+
# use Chrome's native proxy authentication path instead of Playwright's CDP
1424+
# auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse
1425+
# inline credentials, so they fall through to the Playwright proxy dict below.
1426+
if _has_credentials(proxy) and binary_supports_http_proxy_inline_auth(
1427+
license_key, browser_version
1428+
):
14441429
if isinstance(proxy, dict):
14451430
url = _reconstruct_http_url(proxy)
14461431
extra_args = [f"--proxy-server={url}"]

cloakbrowser/config.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,70 @@ def binary_supports_headless_no_viewport(
344344
return False
345345

346346

347+
# ---------------------------------------------------------------------------
348+
# Inline HTTP proxy authentication by binary version
349+
# ---------------------------------------------------------------------------
350+
# First Chromium build, per platform, whose binary can take inline HTTP proxy
351+
# credentials (Chromium-native ``--proxy-server=http://user:pass@host``). Below
352+
# the floor the wrapper MUST route credentialed HTTP/HTTPS proxies through
353+
# Playwright's proxy dict instead — an older binary treats the ``user:pass@``
354+
# authority as an invalid proxy host and drops proxy auth (#182).
355+
#
356+
# A single global threshold (like HEADLESS_NO_VIEWPORT_MIN_VERSION) can't model
357+
# this: the capability landed in different build lineages at different points —
358+
# linux-x64/windows-x64 have it at 146.0.7680.177.5, but the free macOS (145.x)
359+
# and linux-arm64 (146.0.7680.177.3) floors predate it, so those only qualify
360+
# once they resolve to a Pro/newer build (148+). Platforms absent from this map
361+
# are treated as never-inline (unknown capability => safe fallback).
362+
HTTP_PROXY_INLINE_AUTH_MIN_VERSION: dict[str, str] = {
363+
"linux-x64": "146.0.7680.177.5",
364+
"windows-x64": "146.0.7680.177.5",
365+
"linux-arm64": "148.0.7778.215.3",
366+
"darwin-arm64": "148.0.7778.215.3",
367+
"darwin-x64": "148.0.7778.215.3",
368+
}
369+
370+
371+
def binary_supports_http_proxy_inline_auth(
372+
license_key: str | None = None, browser_version: str | None = None
373+
) -> bool:
374+
"""Whether the resolved binary accepts inline HTTP proxy credentials.
375+
376+
The capability is a function of the binary version; ``license_key`` only
377+
resolves *which* version launches (Pro build vs free default), exactly like
378+
``binary_supports_headless_no_viewport``. A declared pin wins, else a valid
379+
Pro license resolves to the Pro build (which ships the patch), else the free
380+
per-platform default — then it compares to this platform's floor. Below the
381+
floor, credentialed HTTP proxies fall back to Playwright's proxy dict. A
382+
local override with no declared version is unknown-version, so stay on the
383+
safe fallback. Python, JS and .NET mirror this gate.
384+
"""
385+
floor = HTTP_PROXY_INLINE_AUTH_MIN_VERSION.get(get_platform_tag())
386+
if floor is None:
387+
return False
388+
try:
389+
declared = normalize_requested_version(browser_version)
390+
except ValueError:
391+
declared = None
392+
if declared:
393+
version = declared
394+
elif get_local_binary_override():
395+
return False
396+
else:
397+
from .license import resolve_license_key
398+
399+
pro = bool(resolve_license_key(license_key))
400+
version = get_effective_version(pro=pro)
401+
if version is None:
402+
# Pro with no cached marker => resolve latest Pro from the server, which
403+
# is >= the floor by construction and always ships the patch.
404+
return True
405+
try:
406+
return not _version_newer(floor, version)
407+
except (ValueError, AttributeError):
408+
return False
409+
410+
347411
def binary_supports_maximized_window(
348412
license_key: str | None = None, browser_version: str | None = None
349413
) -> bool:

dotnet/src/CloakBrowser/CloakLauncher.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public static async Task<CloakBrowserHandle> LaunchAsync(LaunchOptions? options
2626
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false);
2727
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
2828
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
29-
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion);
29+
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey);
3030
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
3131
args = MaybeAppendWebRtcExitIp(args, exitIp);
3232

@@ -136,7 +136,7 @@ public static async Task<CloakContextHandle> LaunchPersistentContextAsync(
136136
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false);
137137
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
138138
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
139-
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion);
139+
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey);
140140
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
141141
args = MaybeAppendWebRtcExitIp(args, exitIp);
142142

dotnet/src/CloakBrowser/Config.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,4 +452,81 @@ public static bool BinarySupportsHeadlessNoViewport(string? licenseKey = null, s
452452
/// </summary>
453453
public static bool BinarySupportsMaximizedWindow(string? licenseKey = null, string? browserVersion = null)
454454
=> BinarySupportsHeadlessNoViewport(licenseKey, browserVersion);
455+
456+
// First Chromium build, per platform, whose binary can take inline HTTP proxy
457+
// credentials (Chromium-native --proxy-server=http://user:pass@host). Below the
458+
// floor the wrapper MUST route credentialed HTTP/HTTPS proxies through
459+
// Playwright's proxy object — an older binary treats the user:pass@ authority as
460+
// an invalid proxy host and drops proxy auth (#182). A single global threshold
461+
// can't model this: the capability landed in different build lineages at different
462+
// points (linux-x64/windows-x64 at 146.0.7680.177.5, but the free macOS 145.x and
463+
// linux-arm64 146.0.7680.177.3 floors predate it, qualifying only once they resolve
464+
// to a Pro/newer 148+ build). Platforms absent from this map are never-inline.
465+
public static readonly IReadOnlyDictionary<string, string> HttpProxyInlineAuthMinVersion =
466+
new Dictionary<string, string>
467+
{
468+
["linux-x64"] = "146.0.7680.177.5",
469+
["windows-x64"] = "146.0.7680.177.5",
470+
["linux-arm64"] = "148.0.7778.215.3",
471+
["darwin-arm64"] = "148.0.7778.215.3",
472+
["darwin-x64"] = "148.0.7778.215.3",
473+
};
474+
475+
/// <summary>
476+
/// Whether the resolved binary accepts inline HTTP proxy credentials. The
477+
/// capability is a function of the binary version; <paramref name="licenseKey"/>
478+
/// only resolves which version launches (Pro build vs free default), exactly like
479+
/// <see cref="BinarySupportsHeadlessNoViewport"/>. A declared pin wins, else a valid
480+
/// Pro license resolves to the Pro build (which ships the patch), else the free
481+
/// per-platform default — then it compares to this platform's floor. Below the
482+
/// floor, credentialed HTTP proxies fall back to Playwright's proxy object. A local
483+
/// override with no declared version is unknown-version, so stay on the safe
484+
/// fallback. Python, JS and .NET mirror this gate.
485+
/// </summary>
486+
public static bool BinarySupportsHttpProxyInlineAuth(string? licenseKey = null, string? browserVersion = null)
487+
{
488+
string tag;
489+
try
490+
{
491+
tag = GetPlatformTag();
492+
}
493+
catch
494+
{
495+
return false;
496+
}
497+
if (!HttpProxyInlineAuthMinVersion.TryGetValue(tag, out var floor))
498+
return false;
499+
string? declared;
500+
try
501+
{
502+
declared = NormalizeRequestedVersion(browserVersion);
503+
}
504+
catch
505+
{
506+
declared = null;
507+
}
508+
string? version;
509+
if (!string.IsNullOrEmpty(declared))
510+
{
511+
version = declared!;
512+
}
513+
else if (GetLocalBinaryOverride() != null)
514+
{
515+
return false;
516+
}
517+
else
518+
{
519+
bool pro = !string.IsNullOrEmpty(License.ResolveLicenseKey(licenseKey));
520+
version = GetEffectiveVersion(pro);
521+
}
522+
if (version == null) return false;
523+
try
524+
{
525+
return !VersionNewer(floor, version);
526+
}
527+
catch
528+
{
529+
return false;
530+
}
531+
}
455532
}

dotnet/src/CloakBrowser/ProxyResolver.cs

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ namespace CloakBrowser;
77
/// </summary>
88
internal static class ProxyResolver
99
{
10-
private const string HttpProxyInlineAuthMinVersion = "146.0.7680.177.5";
11-
private static readonly IReadOnlySet<string> HttpProxyInlineAuthPlatforms =
12-
new HashSet<string> { "linux-x64", "windows-x64" };
13-
1410
/// <summary>Result of resolving a proxy: Playwright proxy (or null) plus extra Chrome args.</summary>
1511
public sealed record ProxyResolution(Microsoft.Playwright.Proxy? PlaywrightProxy, List<string> ExtraArgs);
1612

@@ -267,28 +263,6 @@ private static Microsoft.Playwright.Proxy ParseProxyUrl(string proxy)
267263
return result;
268264
}
269265

270-
private static bool SupportsHttpProxyInlineAuth(string? version = null)
271-
{
272-
string tag = Config.GetPlatformTag();
273-
if (!HttpProxyInlineAuthPlatforms.Contains(tag))
274-
return false;
275-
return Compare(version ?? Config.GetChromiumVersion(), HttpProxyInlineAuthMinVersion) >= 0;
276-
}
277-
278-
private static int Compare(string a, string b)
279-
{
280-
var ta = Config.VersionTuple(a);
281-
var tb = Config.VersionTuple(b);
282-
int n = Math.Max(ta.Length, tb.Length);
283-
for (int i = 0; i < n; i++)
284-
{
285-
int va = i < ta.Length ? ta[i] : 0;
286-
int vb = i < tb.Length ? tb[i] : 0;
287-
if (va != vb) return va.CompareTo(vb);
288-
}
289-
return 0;
290-
}
291-
292266
/// <summary>Extract a normalized proxy URL string from a string or dict proxy (for geoip / webrtc).</summary>
293267
public static string? ExtractProxyUrl(object? proxy)
294268
{
@@ -307,7 +281,7 @@ private static int Compare(string a, string b)
307281
}
308282

309283
/// <summary>Resolve a proxy into Playwright options + extra Chrome args (one or both empty).</summary>
310-
public static ProxyResolution Resolve(object? proxy, string? browserVersion = null)
284+
public static ProxyResolution Resolve(object? proxy, string? browserVersion = null, string? licenseKey = null)
311285
{
312286
if (proxy == null)
313287
return new ProxyResolution(null, new List<string>());
@@ -333,15 +307,16 @@ public static ProxyResolution Resolve(object? proxy, string? browserVersion = nu
333307
return new ProxyResolution(null, new List<string> { $"--proxy-server={NormalizeSocksStringUrl(sUrl)}" });
334308
}
335309

336-
// HTTP/HTTPS with credentials on supported platforms: inline creds via --proxy-server.
310+
// HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth:
311+
// inline creds via --proxy-server. Older binaries (free macOS/linux-arm64)
312+
// can't parse inline credentials, so they fall through to Playwright's proxy.
337313
bool hasCreds = proxy switch
338314
{
339315
ProxySettings ps => HasCredentials(ps),
340316
string s => HasCredentials(s),
341317
_ => false,
342318
};
343-
var requestedVersion = Config.NormalizeRequestedVersion(browserVersion);
344-
if (hasCreds && SupportsHttpProxyInlineAuth(requestedVersion))
319+
if (hasCreds && Config.BinarySupportsHttpProxyInlineAuth(licenseKey, browserVersion))
345320
{
346321
if (proxy is ProxySettings psd)
347322
{

0 commit comments

Comments
 (0)