feat: add --fingerprint-webrtc-ip flag with auto-resolve support

Two ways to spoof WebRTC ICE candidate IPs:

1. --fingerprint-webrtc-ip=auto in args: resolves proxy exit IP via
   HTTP call through the proxy (ipify.org). No extra deps needed.

2. geoip=True: auto-injects the flag for free (exit IP already
   resolved during timezone/locale lookup, zero extra network cost).

Explicit IP (--fingerprint-webrtc-ip=1.2.3.4) also supported.
User-provided values always take precedence.

Python + JS wrappers, README docs, tests.
This commit is contained in:
CloakHQ
2026-04-06 01:16:10 +02:00
parent 25d34dcea3
commit eb4efef329
14 changed files with 306 additions and 73 deletions
+95 -15
View File
@@ -101,7 +101,11 @@ def launch(
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
binary_path = ensure_binary()
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -186,7 +190,11 @@ async def launch_async( # noqa: C901
async_playwright = _import_async_playwright(_resolve_backend(backend))
binary_path = ensure_binary()
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -284,7 +292,11 @@ def launch_persistent_context(
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
@@ -399,7 +411,11 @@ async def launch_persistent_context_async(
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
@@ -497,7 +513,11 @@ def launch_context(
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to binary flags
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
# Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
# --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
@@ -590,28 +610,88 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
"""Extract and normalize proxy URL string from proxy param."""
if proxy is None:
return None
raw = proxy.get("server") if isinstance(proxy, dict) else proxy
if not raw:
return None
return _ensure_proxy_scheme(raw)
def maybe_resolve_geoip(
geoip: bool,
proxy: str | ProxySettings | None,
timezone: str | None,
locale: str | None,
) -> tuple[str | None, str | None]:
"""Auto-fill timezone/locale from proxy IP when geoip is enabled."""
if not geoip or not proxy or (timezone is not None and locale is not None):
return timezone, locale
) -> tuple[str | None, str | None, str | None]:
"""Auto-fill timezone/locale from proxy IP when geoip is enabled.
from .geoip import resolve_proxy_geo
Returns ``(timezone, locale, exit_ip)``. *exit_ip* is a free bonus
from the geoip lookup (no extra HTTP call) — used for WebRTC spoofing.
"""
if not geoip or not proxy:
return timezone, locale, None
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
from .geoip import resolve_proxy_geo_with_ip
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
return timezone, locale
proxy_url = _ensure_proxy_scheme(proxy_url)
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
return timezone, locale, None
# When both tz/locale are explicit, still resolve exit IP for WebRTC
if timezone is not None and locale is not None:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
return timezone, locale, exit_ip
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
if timezone is None:
timezone = geo_tz
if locale is None:
locale = geo_locale
return timezone, locale
return timezone, locale, exit_ip
def _resolve_webrtc_args(
args: list[str] | None,
proxy: str | ProxySettings | None,
) -> list[str] | None:
"""Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
Returns args unchanged if no ``auto`` value is present.
"""
if not args:
return args
idx = None
for i, a in enumerate(args):
if a == "--fingerprint-webrtc-ip=auto":
idx = i
break
if idx is None:
return args
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
logger.debug("--fingerprint-webrtc-ip=auto but no proxy set — removing flag")
args = list(args)
del args[idx]
return args
try:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
except Exception:
logger.debug("WebRTC IP resolution failed — removing flag")
args = list(args)
del args[idx]
return args
if exit_ip:
args = list(args)
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
else:
args = list(args)
del args[idx]
return args
def build_args(
+16 -4
View File
@@ -53,6 +53,18 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
Returns ``(timezone, locale)`` — either or both may be ``None`` on
failure (missing dep, DB download error, lookup miss). Never raises.
"""
tz, locale, _ip = resolve_proxy_geo_with_ip(proxy_url)
return tz, locale
def resolve_proxy_geo_with_ip(
proxy_url: str,
) -> tuple[str | None, str | None, str | None]:
"""Resolve timezone, locale, and exit IP from a proxy.
Returns ``(timezone, locale, exit_ip)``. The exit IP is a free bonus
from the lookup — reused for WebRTC spoofing without an extra HTTP call.
"""
try:
import geoip2.database # noqa: F811
except ImportError:
@@ -63,14 +75,14 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
db_path = _ensure_geoip_db()
if db_path is None:
return None, None
return None, None, None
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
ip = _resolve_exit_ip(proxy_url)
if ip is None:
ip = _resolve_proxy_ip(proxy_url)
if ip is None:
return None, None
return None, None, None
try:
with geoip2.database.Reader(str(db_path)) as reader:
@@ -82,10 +94,10 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
"GeoIP: %s → tz=%s, country=%s, locale=%s",
ip, timezone, country, locale,
)
return timezone, locale
return timezone, locale, ip
except Exception as exc:
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
return None, None
return None, None, ip
# ---------------------------------------------------------------------------