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
+10
View File
@@ -137,6 +137,7 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
- **42 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145 - **42 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required - **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale - **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call)
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection - **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
See the full [CHANGELOG.md](CHANGELOG.md) for details. See the full [CHANGELOG.md](CHANGELOG.md) for details.
@@ -253,11 +254,19 @@ browser = launch(args=["--disable-gpu"])
browser = launch(timezone="America/New_York", locale="en-US") browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip]) # Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
# Also auto-injects --fingerprint-webrtc-ip to prevent WebRTC IP leaks (no extra cost)
# Note: makes HTTP calls through your proxy to resolve exit IP (ipify.org, checkip.amazonaws.com)
browser = launch(proxy="http://proxy:8080", geoip=True) browser = launch(proxy="http://proxy:8080", geoip=True)
# Explicit timezone/locale always win over auto-detection # Explicit timezone/locale always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London") browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
# WebRTC IP spoofing only (no geoip dep needed — resolves exit IP via HTTP call through proxy)
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=auto"])
# Explicit WebRTC IP (no network call)
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=1.2.3.4"])
# Human-like mouse, keyboard, and scroll behavior # Human-like mouse, keyboard, and scroll behavior
browser = launch(humanize=True) browser = launch(humanize=True)
@@ -573,6 +582,7 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set | | `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) | | `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory | | `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active | | `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements | | `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
+9 -2
View File
@@ -36,7 +36,7 @@ import aiohttp
import websockets import websockets
from aiohttp import web from aiohttp import web
from cloakbrowser.browser import build_args, maybe_resolve_geoip from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args
from cloakbrowser.download import ensure_binary from cloakbrowser.download import ensure_binary
logging.basicConfig( logging.basicConfig(
@@ -166,8 +166,9 @@ class ChromePool:
await self._cleanup_process(seed_key) await self._cleanup_process(seed_key)
# Resolve geoip if requested # Resolve geoip if requested
exit_ip = None
if geoip and proxy: if geoip and proxy:
timezone, locale = maybe_resolve_geoip(True, proxy, timezone, locale) timezone, locale, exit_ip = maybe_resolve_geoip(True, proxy, timezone, locale)
# Build Chrome args via shared logic # Build Chrome args via shared logic
fp_extra = [f"--fingerprint={actual_seed}"] fp_extra = [f"--fingerprint={actual_seed}"]
@@ -176,6 +177,12 @@ class ChromePool:
if proxy: if proxy:
fp_extra.append(f"--proxy-server={proxy}") fp_extra.append(f"--proxy-server={proxy}")
# WebRTC IP spoofing: resolve auto, inject geoip exit IP
fp_extra = _resolve_webrtc_args(fp_extra, proxy)
if exit_ip and not any(a.startswith("--fingerprint-webrtc-ip") for a in (fp_extra or [])):
fp_extra = list(fp_extra or [])
fp_extra.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args( chrome_args = build_args(
stealth_args=True, stealth_args=True,
extra_args=fp_extra, extra_args=fp_extra,
+95 -15
View File
@@ -101,7 +101,11 @@ def launch(
sync_playwright = _import_sync_playwright(_resolve_backend(backend)) sync_playwright = _import_sync_playwright(_resolve_backend(backend))
binary_path = ensure_binary() 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) 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)) 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)) async_playwright = _import_async_playwright(_resolve_backend(backend))
binary_path = ensure_binary() 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) 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)) 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) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() 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) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug( logger.debug(
@@ -399,7 +411,11 @@ async def launch_persistent_context_async(
timezone = _resolve_timezone(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() 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) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug( logger.debug(
@@ -497,7 +513,11 @@ def launch_context(
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure # Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to binary flags # 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), # --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# so it applies to ALL contexts, not just the default one. # so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation. # 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}" 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( def maybe_resolve_geoip(
geoip: bool, geoip: bool,
proxy: str | ProxySettings | None, proxy: str | ProxySettings | None,
timezone: str | None, timezone: str | None,
locale: str | None, locale: str | None,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None, str | None]:
"""Auto-fill timezone/locale from proxy IP when geoip is enabled.""" """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
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: if not proxy_url:
return timezone, locale return timezone, locale, None
proxy_url = _ensure_proxy_scheme(proxy_url)
geo_tz, geo_locale = resolve_proxy_geo(proxy_url) # 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: if timezone is None:
timezone = geo_tz timezone = geo_tz
if locale is None: if locale is None:
locale = geo_locale 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( 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 Returns ``(timezone, locale)`` — either or both may be ``None`` on
failure (missing dep, DB download error, lookup miss). Never raises. 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: try:
import geoip2.database # noqa: F811 import geoip2.database # noqa: F811
except ImportError: except ImportError:
@@ -63,14 +75,14 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
db_path = _ensure_geoip_db() db_path = _ensure_geoip_db()
if db_path is None: 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 # Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
ip = _resolve_exit_ip(proxy_url) ip = _resolve_exit_ip(proxy_url)
if ip is None: if ip is None:
ip = _resolve_proxy_ip(proxy_url) ip = _resolve_proxy_ip(proxy_url)
if ip is None: if ip is None:
return None, None return None, None, None
try: try:
with geoip2.database.Reader(str(db_path)) as reader: 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", "GeoIP: %s → tz=%s, country=%s, locale=%s",
ip, timezone, country, locale, ip, timezone, country, locale,
) )
return timezone, locale return timezone, locale, ip
except Exception as exc: except Exception as exc:
logger.debug("GeoIP lookup failed for %s: %s", ip, exc) logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
return None, None return None, None, ip
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+53 -8
View File
@@ -44,6 +44,7 @@ export const COUNTRY_LOCALE_MAP: Record<string, string> = {
export interface GeoResult { export interface GeoResult {
timezone: string | null; timezone: string | null;
locale: string | null; locale: string | null;
exitIp: string | null;
} }
/** /**
@@ -65,12 +66,12 @@ export async function resolveProxyGeo(
} }
const dbPath = await ensureGeoipDb(); const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: null }; if (!dbPath) return { timezone: null, locale: null, exitIp: null };
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit // Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
let ip = await resolveExitIp(proxyUrl); let ip = await resolveExitIp(proxyUrl);
if (!ip) ip = await resolveProxyIp(proxyUrl); if (!ip) ip = await resolveProxyIp(proxyUrl);
if (!ip) return { timezone: null, locale: null }; if (!ip) return { timezone: null, locale: null, exitIp: null };
try { try {
const buf = fs.readFileSync(dbPath); const buf = fs.readFileSync(dbPath);
@@ -80,9 +81,9 @@ export async function resolveProxyGeo(
const countryCode: string | null = result?.country?.iso_code ?? null; const countryCode: string | null = result?.country?.iso_code ?? null;
const locale = const locale =
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null; countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
return { timezone, locale }; return { timezone, locale, exitIp: ip };
} catch { } catch {
return { timezone: null, locale: null }; return { timezone: null, locale: null, exitIp: ip };
} }
} }
@@ -265,20 +266,64 @@ function maybeTriggerUpdate(dbPath: string): void {
/** /**
* Auto-fill timezone/locale from proxy IP when geoip is enabled. * Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Shared by the Playwright and Puppeteer wrappers. * Also returns exitIp as a free bonus (reused for WebRTC spoofing).
*/ */
export async function maybeResolveGeoip( export async function maybeResolveGeoip(
options: LaunchOptions options: LaunchOptions
): Promise<{ timezone?: string; locale?: 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 };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale }; if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
proxyUrl = ensureProxyScheme(proxyUrl); proxyUrl = ensureProxyScheme(proxyUrl);
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
// When both tz/locale are explicit, still resolve exit IP for WebRTC
if (options.timezone && options.locale) {
const exitIp = await resolveExitIp(proxyUrl) ?? undefined;
return { timezone: options.timezone, locale: options.locale, exitIp };
}
const { timezone: geoTz, locale: geoLocale, exitIp: geoExitIp } = await resolveProxyGeo(proxyUrl);
const exitIp = geoExitIp ?? undefined;
return { return {
timezone: options.timezone ?? geoTz ?? undefined, timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined, locale: options.locale ?? geoLocale ?? undefined,
exitIp,
}; };
} }
/**
* Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
* Returns args unchanged if no ``auto`` value is present.
*/
export async function resolveWebrtcArgs(
options: LaunchOptions
): Promise<string[] | undefined> {
const args = options.args;
if (!args) return args;
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
if (idx === -1) return args;
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy?.server;
if (!proxyUrl) {
const result = [...args];
result.splice(idx, 1);
return result;
}
proxyUrl = ensureProxyScheme(proxyUrl);
try {
const ip = await resolveExitIp(proxyUrl);
const result = [...args];
if (ip) {
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
} else {
result.splice(idx, 1);
}
return result;
} catch {
const result = [...args];
result.splice(idx, 1);
return result;
}
}
+20 -7
View File
@@ -9,7 +9,7 @@ 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 { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } 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. */
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T { export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
@@ -38,8 +38,12 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { chromium } = await import("playwright-core"); const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options); const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved }); let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
const browser = await chromium.launch({ const browser = await chromium.launch({
executablePath: binaryPath, executablePath: binaryPath,
@@ -87,11 +91,16 @@ export async function launchContext(
): Promise<BrowserContext> { ): Promise<BrowserContext> {
options = resolveTimezone(options); options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution // Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options); const { exitIp, ...resolved } = await maybeResolveGeoip(options);
let launchArgs = await resolveWebrtcArgs(options);
// Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
if (exitIp && !(launchArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
launchArgs = [...(launchArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
// --fingerprint-timezone is process-wide (reads CommandLine in renderer), // --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// so it applies to ALL contexts, not just the default one. // so it applies to ALL contexts, not just the default one.
// locale and timezone are set via binary flags only — no CDP emulation. // locale and timezone are set via binary flags only — no CDP emulation.
const browser = await launch({ ...options, ...resolved, geoip: false }); const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false });
let context: BrowserContext; let context: BrowserContext;
try { try {
@@ -154,8 +163,12 @@ export async function launchPersistentContext(
const { chromium } = await import("playwright-core"); const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options); const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved }); let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
// 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.
+7 -3
View File
@@ -9,7 +9,7 @@ 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 { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js"; import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/** /**
* Launch stealth Chromium browser via Puppeteer. * Launch stealth Chromium browser via Puppeteer.
@@ -28,8 +28,12 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const puppeteer = await import("puppeteer-core"); const puppeteer = await import("puppeteer-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options); const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
const args = buildArgs({ ...options, ...resolved }); let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
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, // Chromium's --proxy-server does NOT support inline credentials,
+12
View File
@@ -182,6 +182,18 @@ describe("buildArgs deduplication", () => {
}); });
}); });
describe("buildArgs webrtc IP", () => {
it("passes --fingerprint-webrtc-ip from args", () => {
const args = _buildArgsForTest({ args: ["--fingerprint-webrtc-ip=1.2.3.4"] });
expect(args).toContain("--fingerprint-webrtc-ip=1.2.3.4");
});
it("does not inject when not in args", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-webrtc-ip"))).toBe(false);
});
});
describe("resolveTimezone alias", () => { describe("resolveTimezone alias", () => {
it("resolves timezoneId to timezone", () => { it("resolves timezoneId to timezone", () => {
const result = resolveTimezone({ timezoneId: "Europe/Paris" }); const result = resolveTimezone({ timezoneId: "Europe/Paris" });
+1
View File
@@ -14,6 +14,7 @@ vi.mock("../src/download.js", () => ({
vi.mock("../src/geoip.js", () => ({ vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }), resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}), maybeResolveGeoip: vi.fn().mockResolvedValue({}),
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
})); }));
describe("puppeteer launch", () => { describe("puppeteer launch", () => {
+41
View File
@@ -158,3 +158,44 @@ def test_override_logs_debug(caplog):
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"): with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
build_args(stealth_args=True, extra_args=["--fingerprint=99887"]) build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records) assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
# --- WebRTC IP spoofing ---
def test_webrtc_ip_passed_through_args():
"""--fingerprint-webrtc-ip in args should pass through to output."""
args = build_args(stealth_args=True, extra_args=["--fingerprint-webrtc-ip=1.2.3.4"])
assert "--fingerprint-webrtc-ip=1.2.3.4" in args
def test_webrtc_ip_not_present_by_default():
"""No --fingerprint-webrtc-ip when not in args."""
args = build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-webrtc-ip") for a in args)
def test_resolve_webrtc_args_auto():
"""--fingerprint-webrtc-ip=auto should be resolved to an IP."""
from cloakbrowser.browser import _resolve_webrtc_args
from unittest.mock import patch
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="5.6.7.8"):
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=auto"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=5.6.7.8"]
def test_resolve_webrtc_args_explicit_ip_unchanged():
"""Explicit IP in args should not be touched."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=9.9.9.9"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=9.9.9.9"]
def test_resolve_webrtc_args_no_flag():
"""No webrtc flag in args should return args unchanged."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--no-sandbox"], "http://proxy:8080")
assert result == ["--no-sandbox"]
+15 -10
View File
@@ -97,46 +97,51 @@ def test_resolve_geo_returns_none_when_db_missing():
def test_maybe_resolve_skips_when_geoip_false(): def test_maybe_resolve_skips_when_geoip_false():
tz, loc = maybe_resolve_geoip(False, "http://proxy:8080", None, None) tz, loc, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None assert tz is None
assert loc is None assert loc is None
assert ip is None
def test_maybe_resolve_skips_when_no_proxy(): def test_maybe_resolve_skips_when_no_proxy():
tz, loc = maybe_resolve_geoip(True, None, None, None) tz, loc, ip = maybe_resolve_geoip(True, None, None, None)
assert tz is None assert tz is None
assert loc is None assert loc is None
assert ip is None
def test_maybe_resolve_skips_when_both_explicit(): def test_maybe_resolve_skips_when_both_explicit():
"""Explicit values should not trigger geoip resolution.""" """Explicit values should still resolve exit IP for WebRTC."""
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE") with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="1.2.3.4"):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert loc == "de-DE" assert loc == "de-DE"
assert ip == "1.2.3.4"
def test_maybe_resolve_fills_missing_timezone(): def test_maybe_resolve_fills_missing_timezone():
"""When only locale is explicit, geoip should fill timezone.""" """When only locale is explicit, geoip should fill timezone."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")): with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR") tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
assert tz == "America/New_York" assert tz == "America/New_York"
assert loc == "fr-FR" # Explicit wins assert loc == "fr-FR" # Explicit wins
def test_maybe_resolve_fills_missing_locale(): def test_maybe_resolve_fills_missing_locale():
"""When only timezone is explicit, geoip should fill locale.""" """When only timezone is explicit, geoip should fill locale."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")): with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None) tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
assert tz == "Asia/Tokyo" # Explicit wins assert tz == "Asia/Tokyo" # Explicit wins
assert loc == "en-US" assert loc == "en-US"
def test_maybe_resolve_fills_both(): def test_maybe_resolve_fills_both():
"""When neither is set, geoip should fill both.""" """When neither is set, geoip should fill both."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")): with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")):
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, None) tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert loc == "de-DE" assert loc == "de-DE"
assert ip == "5.6.7.8"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -114,7 +114,7 @@ def test_color_scheme(mock_launch, _mock_bin):
assert ctx_kwargs[1]["color_scheme"] == "dark" assert ctx_kwargs[1]["color_scheme"] == "dark"
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE")) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip): def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
+11 -11
View File
@@ -26,7 +26,7 @@ def _make_mock_pw_and_context():
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_args_built(_mock_geoip, _mock_bin): def test_persistent_context_args_built(_mock_geoip, _mock_bin):
"""Stealth args + extra args combined correctly.""" """Stealth args + extra args combined correctly."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -42,7 +42,7 @@ def test_persistent_context_args_built(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin): def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given.""" """DEFAULT_VIEWPORT applied when no viewport given."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -56,7 +56,7 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin): def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
"""Custom viewport overrides DEFAULT_VIEWPORT.""" """Custom viewport overrides DEFAULT_VIEWPORT."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -71,7 +71,7 @@ def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_user_agent(_mock_geoip, _mock_bin): def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
"""user_agent forwarded to launch_persistent_context().""" """user_agent forwarded to launch_persistent_context()."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -103,7 +103,7 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin): def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
"""color_scheme forwarded correctly.""" """color_scheme forwarded correctly."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -116,7 +116,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
assert call_kwargs["color_scheme"] == "dark" assert call_kwargs["color_scheme"] == "dark"
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE")) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip): def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale — flows to binary args, not CDP context.""" """geoip fills missing tz/locale — flows to binary args, not CDP context."""
@@ -150,7 +150,7 @@ def test_persistent_context_timezone_id_alias(_mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin): def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
"""context.close() also calls pw.stop().""" """context.close() also calls pw.stop()."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -166,7 +166,7 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin): def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
"""Proxy string parsed and passed.""" """Proxy string parsed and passed."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -182,7 +182,7 @@ def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin): def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
"""Proxy dict passed through.""" """Proxy dict passed through."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -213,7 +213,7 @@ def _make_mock_async_pw_and_context():
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin): async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
"""Async launch builds args correctly.""" """Async launch builds args correctly."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
@@ -229,7 +229,7 @@ async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin): async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
"""await context.close() calls await pw.stop().""" """await context.close() calls await pw.stop()."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
+15 -12
View File
@@ -68,49 +68,52 @@ class TestBuildProxyKwargs:
class TestMaybeResolveGeoip: class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")) @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
def test_geoip_with_string_proxy(self, mock_geo): def test_geoip_with_string_proxy(self, mock_geo):
tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", None, None) tz, locale, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
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"
assert locale == "en-US" assert locale == "en-US"
assert ip == "1.2.3.4"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB")) @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "5.6.7.8"))
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo): def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"} proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
tz, locale = maybe_resolve_geoip(True, proxy_dict, None, None) tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080") mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "Europe/London" assert tz == "Europe/London"
assert locale == "en-GB" assert locale == "en-GB"
def test_geoip_disabled_skips_resolution(self): def test_geoip_disabled_skips_resolution(self):
tz, locale = maybe_resolve_geoip(False, "http://proxy:8080", None, None) tz, locale, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None assert tz is None
assert locale is None assert locale is None
assert ip is None
def test_geoip_no_proxy_skips_resolution(self): def test_geoip_no_proxy_skips_resolution(self):
tz, locale = maybe_resolve_geoip(True, None, None, None) tz, locale, ip = maybe_resolve_geoip(True, None, None, None)
assert tz is None assert tz is None
assert locale is None assert locale is None
assert ip is None
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP")) @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Asia/Tokyo", "ja-JP", "9.8.7.6"))
def test_geoip_preserves_explicit_timezone(self, mock_geo): def test_geoip_preserves_explicit_timezone(self, mock_geo):
tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None) tz, locale, _ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert locale == "ja-JP" assert locale == "ja-JP"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")) @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo): def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
# "user:pass@host:port" must be normalized to http:// before geoip lookup. # "user:pass@host:port" must be normalized to http:// before geoip lookup.
tz, locale = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None) tz, locale, _ip = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
mock_geo.assert_called_once_with("http://user:pass@proxy:8080") mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
assert tz == "America/New_York" assert tz == "America/New_York"
assert locale == "en-US" assert locale == "en-US"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")) @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo): def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
# "host:port" (no @ and no scheme) must also be normalized. # "host:port" (no @ and no scheme) must also be normalized.
tz, locale = maybe_resolve_geoip(True, "proxy:8080", None, None) tz, locale, _ip = maybe_resolve_geoip(True, "proxy:8080", None, None)
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"