feat: native SOCKS5 proxy support in proxy= parameter

Route SOCKS5/SOCKS5h proxies via --proxy-server Chrome arg instead of
Playwright's proxy dict (which rejects SOCKS5 with credentials).
Handles string URLs, Playwright dicts, IPv6, bypass lists.

SOCKS5 geoip exit IP resolution uses socks-proxy-agent (optional peer
dep). Falls back to DNS if not installed.
This commit is contained in:
CloakHQ
2026-04-10 22:20:16 +02:00
parent 2be8cdcc03
commit cb0b87873e
14 changed files with 543 additions and 80 deletions
+3 -2
View File
@@ -242,8 +242,9 @@ browser = launch()
# Headed mode (see the browser window) # Headed mode (see the browser window)
browser = launch(headless=False) browser = launch(headless=False)
# With proxy # With proxy (HTTP or SOCKS5)
browser = launch(proxy="http://user:pass@proxy:8080") browser = launch(proxy="http://user:pass@proxy:8080")
browser = launch(proxy="socks5://user:pass@proxy:1080")
# With proxy dict (bypass, separate auth fields) # With proxy dict (bypass, separate auth fields)
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"}) browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
@@ -1028,7 +1029,7 @@ A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright
A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves. A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves.
**Q: Can I use my own proxy?** **Q: Can I use my own proxy?**
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`. A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@host:port"` to `launch()`. Both HTTP and SOCKS5 proxies are supported natively.
## Roadmap ## Roadmap
+85 -21
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import logging import logging
import os import os
from typing import Any, Literal, TypedDict from typing import Any, Literal, TypedDict
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import quote, unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
from .download import ensure_binary from .download import ensure_binary
@@ -105,11 +105,12 @@ def launch(
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
args = _resolve_webrtc_args(args, proxy) args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)): if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or []) args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}") args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless) chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -119,7 +120,7 @@ def launch(
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS, ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy), **proxy_kwargs,
**kwargs, **kwargs,
) )
@@ -194,11 +195,12 @@ async def launch_async( # noqa: C901
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
args = _resolve_webrtc_args(args, proxy) args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)): if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or []) args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}") args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless) chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -208,7 +210,7 @@ async def launch_async( # noqa: C901
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS, ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy), **proxy_kwargs,
**kwargs, **kwargs,
) )
@@ -297,11 +299,12 @@ def launch_persistent_context(
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
args = _resolve_webrtc_args(args, proxy) args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)): if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or []) args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}") args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless) chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug( logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)", "Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
@@ -331,7 +334,7 @@ def launch_persistent_context(
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS, ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy), **proxy_kwargs,
**context_kwargs, **context_kwargs,
) )
@@ -422,11 +425,12 @@ async def launch_persistent_context_async(
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
args = _resolve_webrtc_args(args, proxy) args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)): if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or []) args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}") args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless) chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug( logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)", "Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
@@ -456,7 +460,7 @@ async def launch_persistent_context_async(
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS, ignore_default_args=IGNORE_DEFAULT_ARGS,
**_build_proxy_kwargs(proxy), **proxy_kwargs,
**context_kwargs, **context_kwargs,
) )
@@ -631,14 +635,42 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
return proxy_url if "://" in proxy_url else f"http://{proxy_url}" return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _reconstruct_socks_url(proxy: ProxySettings) -> str:
"""Reconstruct a SOCKS5 URL with inline credentials from a Playwright proxy dict."""
server = proxy.get("server", "")
username = proxy.get("username", "")
password = proxy.get("password", "")
if not username:
return server
parsed = urlparse(server)
creds = quote(username, safe="")
if password:
creds += f":{quote(password, safe='')}"
host = parsed.hostname or ""
if ":" in host: # IPv6 literal — re-add brackets
host = f"[{host}]"
netloc = f"{creds}@{host}"
if parsed.port:
netloc += f":{parsed.port}"
return urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None: def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
"""Extract and normalize proxy URL string from proxy param.""" """Extract and normalize proxy URL string from proxy param.
For SOCKS5 dicts with separate username/password fields, reconstructs
the full URL with inline credentials so SOCKS5 auth works.
"""
if proxy is None: if proxy is None:
return None return None
raw = proxy.get("server") if isinstance(proxy, dict) else proxy if isinstance(proxy, dict):
if not raw: server = proxy.get("server", "")
return None if not server:
return _ensure_proxy_scheme(raw) return None
if _is_socks_proxy(proxy):
return _reconstruct_socks_url(proxy)
return _ensure_proxy_scheme(server)
return _ensure_proxy_scheme(proxy)
def maybe_resolve_geoip( def maybe_resolve_geoip(
@@ -694,7 +726,7 @@ def _resolve_webrtc_args(
return args return args
proxy_url = _extract_proxy_url(proxy) proxy_url = _extract_proxy_url(proxy)
if not proxy_url: if not proxy_url:
logger.debug("--fingerprint-webrtc-ip=auto but no proxy set — removing flag") logger.warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag")
args = list(args) args = list(args)
del args[idx] del args[idx]
return args return args
@@ -702,7 +734,7 @@ def _resolve_webrtc_args(
from .geoip import _resolve_exit_ip from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url) exit_ip = _resolve_exit_ip(proxy_url)
except Exception: except Exception:
logger.debug("WebRTC IP resolution failed — removing flag") logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args) args = list(args)
del args[idx] del args[idx]
return args return args
@@ -710,6 +742,7 @@ def _resolve_webrtc_args(
args = list(args) args = list(args)
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}" args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
else: else:
logger.warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args) args = list(args)
del args[idx] del args[idx]
return args return args
@@ -799,10 +832,41 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
return result return result
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]: def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
"""Build proxy kwargs for Playwright launch.""" """Check if the proxy uses SOCKS5 protocol."""
if proxy is None: if proxy is None:
return {} return False
url = proxy.get("server", "") if isinstance(proxy, dict) else proxy
return url.lower().startswith(("socks5://", "socks5h://"))
def _resolve_proxy_config(
proxy: str | ProxySettings | None,
) -> tuple[dict[str, Any], list[str]]:
"""Resolve proxy into Playwright kwargs and Chrome args.
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
so SOCKS5 is passed via --proxy-server Chrome arg instead.
Returns:
(proxy_kwargs, extra_chrome_args) one or both will be empty.
"""
if proxy is None:
return {}, []
if _is_socks_proxy(proxy):
# SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
# Chrome handles SOCKS5 auth natively from the URL.
if isinstance(proxy, dict):
url = _reconstruct_socks_url(proxy)
extra_args = [f"--proxy-server={url}"]
if proxy.get("bypass"):
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
return {}, extra_args
# String URL — pass as-is (Chrome handles user:pass@ in the URL)
return {}, [f"--proxy-server={proxy}"]
# HTTP/HTTPS: use Playwright's proxy dict as before
if isinstance(proxy, dict): if isinstance(proxy, dict):
return {"proxy": proxy} return {"proxy": proxy}, []
return {"proxy": _parse_proxy_url(proxy)} return {"proxy": _parse_proxy_url(proxy)}, []
+8 -3
View File
@@ -96,7 +96,7 @@ def resolve_proxy_geo_with_ip(
) )
return timezone, locale, ip return timezone, locale, ip
except Exception as exc: except Exception as exc:
logger.debug("GeoIP lookup failed for %s: %s", ip, exc) logger.warning("GeoIP lookup failed for %s: %s", ip, exc)
return None, None, ip return None, None, ip
@@ -132,7 +132,7 @@ def _resolve_proxy_ip(proxy_url: str) -> str | None:
return ip return ip
return None return None
except Exception as exc: except Exception as exc:
logger.debug("Failed to resolve proxy hostname: %s", exc) logger.warning("Failed to resolve proxy hostname: %s", exc)
return None return None
@@ -165,9 +165,14 @@ def _resolve_exit_ip(proxy_url: str) -> str | None:
ipaddress.ip_address(ip) ipaddress.ip_address(ip)
logger.debug("Exit IP via %s: %s", url, ip) logger.debug("Exit IP via %s: %s", url, ip)
return ip return ip
except httpx.UnsupportedProtocol:
logger.warning(
"SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]"
)
return None
except Exception: except Exception:
continue continue
logger.debug("Failed to discover exit IP through proxy") logger.warning("Failed to discover exit IP through proxy")
return None return None
+5 -2
View File
@@ -63,10 +63,13 @@ await browser.close();
```javascript ```javascript
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser'; import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
// With proxy // With proxy (HTTP or SOCKS5)
const browser = await launch({ const browser = await launch({
proxy: 'http://user:pass@proxy:8080', proxy: 'http://user:pass@proxy:8080',
}); });
const browser = await launch({
proxy: 'socks5://user:pass@proxy:1080',
});
// With proxy object (bypass, separate auth fields) // With proxy object (bypass, separate auth fields)
const browser = await launch({ const browser = await launch({
@@ -211,7 +214,7 @@ const page = await browser.newPage();
## Requirements ## Requirements
- Node.js >= 18 - Node.js >= 20
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21 - One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
## Troubleshooting ## Troubleshooting
+57 -9
View File
@@ -1,31 +1,36 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.9", "version": "0.3.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.9", "version": "0.3.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tar": "^7.0.0" "tar": "^7.0.0"
}, },
"bin": {
"cloakbrowser": "dist/cli.js"
},
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2", "mmdb-lib": "^3.0.2",
"playwright-core": "^1.40.0", "playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0", "puppeteer-core": "^21.0.0",
"socks-proxy-agent": "^10.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vitest": "^1.0.0" "vitest": "^1.0.0"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=20.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"mmdb-lib": ">=2.0.0", "mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0", "playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0" "puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"mmdb-lib": { "mmdb-lib": {
@@ -36,6 +41,9 @@
}, },
"puppeteer-core": { "puppeteer-core": {
"optional": true "optional": true
},
"socks-proxy-agent": {
"optional": true
} }
} }
}, },
@@ -2003,6 +2011,21 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/pac-proxy-agent/node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/pac-resolver": { "node_modules/pac-resolver": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
@@ -2164,6 +2187,21 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/proxy-agent/node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2332,18 +2370,28 @@
} }
}, },
"node_modules/socks-proxy-agent": { "node_modules/socks-proxy-agent": {
"version": "8.0.5", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "9.0.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"socks": "^2.8.3" "socks": "^2.8.3"
}, },
"engines": { "engines": {
"node": ">= 14" "node": ">= 20"
}
},
"node_modules/socks-proxy-agent/node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
} }
}, },
"node_modules/source-map": { "node_modules/source-map": {
+7 -2
View File
@@ -55,12 +55,13 @@
}, },
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs", "homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=20.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"mmdb-lib": ">=2.0.0", "mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0", "playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0" "puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=10.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"playwright-core": { "playwright-core": {
@@ -71,6 +72,9 @@
}, },
"mmdb-lib": { "mmdb-lib": {
"optional": true "optional": true
},
"socks-proxy-agent": {
"optional": true
} }
}, },
"dependencies": { "dependencies": {
@@ -79,6 +83,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2", "mmdb-lib": "^3.0.2",
"socks-proxy-agent": "^10.0.0",
"playwright-core": "^1.40.0", "playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0", "puppeteer-core": "^21.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
+60 -8
View File
@@ -15,7 +15,7 @@ import dns from "node:dns/promises";
import net from "node:net"; import net from "node:net";
import { getCacheDir } from "./config.js"; import { getCacheDir } from "./config.js";
import type { LaunchOptions } from "./types.js"; import type { LaunchOptions } from "./types.js";
import { ensureProxyScheme } from "./proxy.js"; import { ensureProxyScheme, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed // P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL = const GEOIP_DB_URL =
@@ -129,9 +129,44 @@ const IP_ECHO_URLS = [
]; ];
async function resolveExitIp(proxyUrl: string): Promise<string | null> { async function resolveExitIp(proxyUrl: string): Promise<string | null> {
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http const isSocks = isSocksProxy(proxyUrl);
// For simplicity, use a direct HTTP request to a plain-text IP echo service
// through the proxy using Node's http module // SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
if (isSocks) {
let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent;
try {
({ SocksProxyAgent } = await import("socks-proxy-agent"));
} catch {
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
return null;
}
const { default: https } = await import("node:https");
const agent = new SocksProxyAgent(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) {
try {
const ip = await new Promise<string | null>((resolve) => {
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => {
const ip = data.trim();
resolve(net.isIP(ip) ? ip : null);
});
});
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
req.end();
});
if (ip) return ip;
} catch {
continue;
}
}
return null;
}
// HTTP/HTTPS: use a CONNECT tunnel via http
try { try {
const { default: http } = await import("node:http"); const { default: http } = await import("node:http");
const { default: https } = await import("node:https"); const { default: https } = await import("node:https");
@@ -264,6 +299,22 @@ function maybeTriggerUpdate(dbPath: string): void {
downloadGeoipDb(dbPath).catch(() => {}); downloadGeoipDb(dbPath).catch(() => {});
} }
/**
* Extract a usable proxy URL from LaunchOptions.proxy.
* For SOCKS5 dicts with separate credentials, reconstructs the full URL
* with inline credentials so SOCKS5 auth works.
*/
function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null {
if (!proxy) return null;
if (typeof proxy === "string") return ensureProxyScheme(proxy);
const p = proxy as ProxyDict;
if (!p.server) return null;
if (p.username && isSocksProxy(p)) {
return reconstructSocksUrl(p);
}
return ensureProxyScheme(p.server);
}
/** /**
* Auto-fill timezone/locale from proxy IP when geoip is enabled. * Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Also returns exitIp as a free bonus (reused for WebRTC spoofing). * Also returns exitIp as a free bonus (reused for WebRTC spoofing).
@@ -273,9 +324,8 @@ export async function maybeResolveGeoip(
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> { ): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale }; if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; const proxyUrl = extractProxyUrl(options.proxy);
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale }; if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
proxyUrl = ensureProxyScheme(proxyUrl);
// When both tz/locale are explicit, still resolve exit IP for WebRTC // When both tz/locale are explicit, still resolve exit IP for WebRTC
if (options.timezone && options.locale) { if (options.timezone && options.locale) {
@@ -304,13 +354,13 @@ export async function resolveWebrtcArgs(
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto"); const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
if (idx === -1) return args; if (idx === -1) return args;
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy?.server; const proxyUrl = extractProxyUrl(options.proxy);
if (!proxyUrl) { if (!proxyUrl) {
console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag");
const result = [...args]; const result = [...args];
result.splice(idx, 1); result.splice(idx, 1);
return result; return result;
} }
proxyUrl = ensureProxyScheme(proxyUrl);
try { try {
const ip = await resolveExitIp(proxyUrl); const ip = await resolveExitIp(proxyUrl);
@@ -318,10 +368,12 @@ export async function resolveWebrtcArgs(
if (ip) { if (ip) {
result[idx] = `--fingerprint-webrtc-ip=${ip}`; result[idx] = `--fingerprint-webrtc-ip=${ip}`;
} else { } else {
console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
result.splice(idx, 1); result.splice(idx, 1);
} }
return result; return result;
} catch { } catch {
console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
const result = [...args]; const result = [...args];
result.splice(idx, 1); result.splice(idx, 1);
return result; return result;
+7 -9
View File
@@ -8,7 +8,7 @@ import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOption
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js"; import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js"; import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js"; import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */ /** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
@@ -39,20 +39,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = await maybeResolveGeoip(options); const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
let resolvedArgs = await resolveWebrtcArgs(options); let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) { if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`]; resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
} }
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs }); const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
const browser = await chromium.launch({ const browser = await chromium.launch({
executablePath: binaryPath, executablePath: binaryPath,
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy ...(proxyOption ? { proxy: proxyOption } : {}),
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...options.launchOptions, ...options.launchOptions,
}); });
@@ -164,11 +163,12 @@ export async function launchPersistentContext(
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = await maybeResolveGeoip(options); const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
let resolvedArgs = await resolveWebrtcArgs(options); let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) { if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`]; resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
} }
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs }); const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone) // locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation. // — NOT via Playwright context kwargs which use detectable CDP emulation.
@@ -177,9 +177,7 @@ export async function launchPersistentContext(
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(options.proxy ...(proxyOption ? { proxy: proxyOption } : {}),
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport, viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
+59
View File
@@ -23,6 +23,65 @@ export function ensureProxyScheme(proxyUrl: string): string {
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port, * Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http). * and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
*/ */
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
export interface ProxyConfig {
/** Playwright proxy option (for HTTP proxies). */
proxyOption?: ParsedProxy;
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
proxyArgs: string[];
}
/**
* Check if a proxy uses the SOCKS5 protocol.
*/
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
if (!proxy) return false;
const url = typeof proxy === "string" ? proxy : proxy.server;
return /^socks5h?:\/\//i.test(url);
}
/**
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
*/
export function reconstructSocksUrl(proxy: ProxyDict): string {
const url = new URL(proxy.server);
if (proxy.username) {
url.username = encodeURIComponent(proxy.username);
if (proxy.password) url.password = encodeURIComponent(proxy.password);
}
return url.href.replace(/\/$/, "");
}
/**
* 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.
*/
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
if (isSocksProxy(proxy)) {
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${proxy}`] };
}
const socksUrl = reconstructSocksUrl(proxy);
const args = [`--proxy-server=${socksUrl}`];
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
return { proxyArgs: args };
}
// HTTP/HTTPS: use Playwright's proxy dict
if (typeof proxy === "string") {
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
}
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
}
export function parseProxyUrl(proxy: string): ParsedProxy { export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL; let url: URL;
// Bare format: "user:pass@host:port" — new URL() throws without a scheme. // Bare format: "user:pass@host:port" — new URL() throws without a scheme.
+9 -7
View File
@@ -9,7 +9,7 @@ import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js"; import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js"; import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js"; import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/** /**
@@ -39,25 +39,27 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs }); const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
// Puppeteer handles proxy via CLI args, not a separate option. // Puppeteer handles proxy via CLI args, not a separate option.
// Chromium's --proxy-server does NOT support inline credentials, // SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
// so we strip them and use page.authenticate() instead. // HTTP: Chrome does NOT support inline credentials — strip them and
// use page.authenticate() for Proxy-Authorization headers instead.
let proxyAuth: { username: string; password: string } | undefined; let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) { if (options.proxy) {
if (typeof options.proxy === "string") { if (isSocksProxy(options.proxy)) {
// SOCKS5: pass full URL with credentials to Chrome directly
const { proxyArgs } = resolveProxyConfig(options.proxy);
args.push(...proxyArgs);
} else if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy); const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`); args.push(`--proxy-server=${server}`);
if (username) { if (username) {
proxyAuth = { username, password: password ?? "" }; proxyAuth = { username, password: password ?? "" };
} }
} else { } else {
// Strip any inline credentials from the server URL — Chromium's
// --proxy-server doesn't support them; use page.authenticate() instead.
const parsed = parseProxyUrl(options.proxy.server); const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`); args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) { if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`); args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
} }
// Explicit username/password fields take precedence over inline creds
const username = options.proxy.username ?? parsed.username; const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password; const password = options.proxy.password ?? parsed.password;
if (username) { if (username) {
+90 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js"; import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js"; import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => { describe("parseProxyUrl", () => {
@@ -115,3 +115,92 @@ describe("bare proxy format (user:pass@host:port)", () => {
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" }); expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
}); });
}); });
describe("isSocksProxy", () => {
it("detects socks5 string", () => {
expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true);
});
it("detects socks5h string", () => {
expect(isSocksProxy("socks5h://host:1080")).toBe(true);
});
it("case insensitive", () => {
expect(isSocksProxy("SOCKS5://host:1080")).toBe(true);
});
it("rejects http", () => {
expect(isSocksProxy("http://host:8080")).toBe(false);
});
it("detects socks5 dict", () => {
expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true);
});
it("rejects http dict", () => {
expect(isSocksProxy({ server: "http://host:8080" })).toBe(false);
});
it("returns false for undefined", () => {
expect(isSocksProxy(undefined)).toBe(false);
});
});
describe("resolveProxyConfig", () => {
it("returns empty for undefined", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
expect(proxyOption).toBeUndefined();
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 dict", () => {
const proxy = { server: "http://proxy:8080", bypass: ".example.com" };
const { proxyOption, proxyArgs } = resolveProxyConfig(proxy);
expect(proxyOption).toEqual(proxy);
expect(proxyArgs).toEqual([]);
});
it("returns chrome arg for socks5 string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]);
});
it("returns chrome arg for socks5 no auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
});
it("returns chrome arg for socks5h string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]);
});
it("reconstructs URL from socks5 dict with auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
username: "user",
password: "p@ss",
});
expect(proxyOption).toBeUndefined();
expect(proxyArgs.length).toBe(1);
expect(proxyArgs[0]).toContain("--proxy-server=socks5://user:p%40ss@host:1080");
});
it("includes bypass for socks5 dict", () => {
const { proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
bypass: ".example.com",
});
expect(proxyArgs).toContain("--proxy-server=socks5://host:1080");
expect(proxyArgs).toContain("--proxy-bypass-list=.example.com");
});
});
+25
View File
@@ -113,4 +113,29 @@ describe("puppeteer launch", () => {
expect(callArgs.args).toContain("--disable-gpu"); expect(callArgs.args).toContain("--disable-gpu");
expect(callArgs.args).toContain("--no-first-run"); expect(callArgs.args).toContain("--no-first-run");
}); });
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
// Should NOT set up page.authenticate for SOCKS5
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({
proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" },
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
}); });
+1 -1
View File
@@ -54,7 +54,7 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
geoip = ["geoip2>=4.0"] geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
patchright = ["patchright>=1.40"] patchright = ["patchright>=1.40"]
serve = ["aiohttp>=3.9", "websockets>=12.0"] serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"] dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
+127 -15
View File
@@ -2,7 +2,12 @@
from unittest.mock import patch from unittest.mock import patch
from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url from cloakbrowser.browser import (
_is_socks_proxy,
_parse_proxy_url,
_resolve_proxy_config,
maybe_resolve_geoip,
)
class TestParseProxyUrl: class TestParseProxyUrl:
@@ -38,23 +43,30 @@ class TestParseProxyUrl:
class TestBuildProxyKwargs: class TestBuildProxyKwargs:
"""Tests for _resolve_proxy_config (formerly _build_proxy_kwargs) HTTP path."""
def test_none(self): def test_none(self):
assert _build_proxy_kwargs(None) == {} kwargs, args = _resolve_proxy_config(None)
assert kwargs == {}
assert args == []
def test_simple_proxy(self): def test_simple_proxy(self):
result = _build_proxy_kwargs("http://proxy:8080") kwargs, args = _resolve_proxy_config("http://proxy:8080")
assert result == {"proxy": {"server": "http://proxy:8080"}} assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
assert args == []
def test_proxy_with_auth(self): def test_proxy_with_auth(self):
result = _build_proxy_kwargs("http://user:pass@proxy:8080") kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert result == { assert kwargs == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"} "proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
} }
assert args == []
def test_proxy_dict_passthrough(self): def test_proxy_dict_passthrough(self):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"} proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
result = _build_proxy_kwargs(proxy_dict) kwargs, args = _resolve_proxy_config(proxy_dict)
assert result == {"proxy": proxy_dict} assert kwargs == {"proxy": proxy_dict}
assert args == []
def test_proxy_dict_with_auth(self): def test_proxy_dict_with_auth(self):
proxy_dict = { proxy_dict = {
@@ -63,8 +75,9 @@ class TestBuildProxyKwargs:
"password": "pass", "password": "pass",
"bypass": ".example.com", "bypass": ".example.com",
} }
result = _build_proxy_kwargs(proxy_dict) kwargs, args = _resolve_proxy_config(proxy_dict)
assert result == {"proxy": proxy_dict} assert kwargs == {"proxy": proxy_dict}
assert args == []
class TestMaybeResolveGeoip: class TestMaybeResolveGeoip:
@@ -117,6 +130,27 @@ class TestMaybeResolveGeoip:
mock_geo.assert_called_once_with("http://proxy:8080") mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York" assert tz == "America/New_York"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
def test_geoip_socks5_dict_reconstructs_credentials(self, mock_geo):
proxy_dict = {"server": "socks5://proxy:1080", "username": "user", "password": "pass"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("socks5://user:pass@proxy:1080")
assert tz == "Europe/Berlin"
assert locale == "de-DE"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
def test_geoip_socks5_dict_no_auth_uses_server(self, mock_geo):
proxy_dict = {"server": "socks5://proxy:1080"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("socks5://proxy:1080")
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "1.1.1.1"))
def test_geoip_http_dict_does_not_inline_creds(self, mock_geo):
# HTTP dict: credentials stay separate, only server URL passed
proxy_dict = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
class TestBareProxyFormat: class TestBareProxyFormat:
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme).""" """_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
@@ -149,8 +183,86 @@ class TestBareProxyFormat:
r = _parse_proxy_url("proxy:8080") r = _parse_proxy_url("proxy:8080")
assert r == {"server": "proxy:8080"} assert r == {"server": "proxy:8080"}
def test_build_proxy_kwargs_bare(self): def test_resolve_proxy_config_bare(self):
r = _build_proxy_kwargs("user:pass@proxy:8080") kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
assert r["proxy"]["username"] == "user" assert kwargs["proxy"]["username"] == "user"
assert r["proxy"]["password"] == "pass" assert kwargs["proxy"]["password"] == "pass"
assert "user" not in r["proxy"]["server"] assert "user" not in kwargs["proxy"]["server"]
class TestIsSocksProxy:
def test_socks5_string(self):
assert _is_socks_proxy("socks5://user:pass@host:1080") is True
def test_socks5h_string(self):
assert _is_socks_proxy("socks5h://host:1080") is True
def test_socks5_uppercase(self):
assert _is_socks_proxy("SOCKS5://host:1080") is True
def test_http_string(self):
assert _is_socks_proxy("http://host:8080") is False
def test_dict_socks5(self):
assert _is_socks_proxy({"server": "socks5://host:1080"}) is True
def test_dict_http(self):
assert _is_socks_proxy({"server": "http://host:8080"}) is False
def test_none(self):
assert _is_socks_proxy(None) is False
class TestResolveProxyConfig:
def test_none(self):
kwargs, args = _resolve_proxy_config(None)
assert kwargs == {}
assert args == []
def test_http_string_returns_playwright_dict(self):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert kwargs["proxy"]["server"] == "http://proxy:8080"
assert kwargs["proxy"]["username"] == "user"
assert args == []
def test_http_dict_passthrough(self):
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {"proxy": proxy}
assert args == []
def test_socks5_string_returns_chrome_arg(self):
kwargs, args = _resolve_proxy_config("socks5://user:pass@host:1080")
assert kwargs == {}
assert args == ["--proxy-server=socks5://user:pass@host:1080"]
def test_socks5_no_auth_returns_chrome_arg(self):
kwargs, args = _resolve_proxy_config("socks5://host:1080")
assert kwargs == {}
assert args == ["--proxy-server=socks5://host:1080"]
def test_socks5h_returns_chrome_arg(self):
kwargs, args = _resolve_proxy_config("socks5h://user:pass@host:1080")
assert kwargs == {}
assert args == ["--proxy-server=socks5h://user:pass@host:1080"]
def test_socks5_dict_reconstructs_url(self):
proxy = {"server": "socks5://host:1080", "username": "user", "password": "p@ss"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert len(args) == 1
assert args[0].startswith("--proxy-server=socks5://user:p%40ss@host:1080")
def test_socks5_dict_ipv6_preserves_brackets(self):
proxy = {"server": "socks5://[::1]:1080", "username": "user", "password": "pass"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert "[::1]" in args[0]
def test_socks5_dict_with_bypass(self):
proxy = {"server": "socks5://host:1080", "bypass": ".example.com"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert "--proxy-server=socks5://host:1080" in args
assert "--proxy-bypass-list=.example.com" in args