fix: harden geoip proxy-free + maximize parity (review follow-ups)

Follow-up fixes for the geoip-no-proxy and start-maximized commits:

- geoip: resolve the egress IP before the DB check so a DB-download failure
  still yields the WebRTC exit IP — the proxy WebRTC spoof survives a DB hiccup
- geoip: skip the exit-IP echo call on no-proxy + explicit tz/locale (the WebRTC
  IP would just be the real connection IP the site already sees — a no-op)
- launch_context: suppress auto --start-maximized when the caller set an explicit
  viewport, matching JS across Python and .NET
- docker: poll for X readiness before starting openbox instead of a blind sleep
  (avoids a silent window-manager startup race)
- refactor: extract the WebRTC exit-IP append into one helper per wrapper
- remove a dead isPrivateIp in the JS wrapper
- tests: fix stale no-proxy geoip assertions; add launch_context parity + .NET
  geoip coverage

Python, JS and .NET wrappers kept in parity.
This commit is contained in:
CloakHQ committed 2026-07-04 23:21:20 +02:00
1 parent d029f2170f
commit 01c6105128
16 files changed
+225 -78

No files matched your search

+10 -2
View File
@@ -9,12 +9,20 @@ rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 1
# Wait for the X server to actually accept connections before starting the WM.
# A blind `sleep 1` races under a CPU-starved start: openbox can come up before
# X is ready, fail to connect, and never retry — leaving --start-maximized a
# silent no-op for the container's whole life. Poll instead (xdotool is already
# installed and needs a live X server to answer). Bounded to ~10s.
for _ in $(seq 1 50); do
DISPLAY=:99 xdotool getdisplaygeometry >/dev/null 2>&1 && break
sleep 0.2
done
# Window manager so headed --start-maximized is honored (bare Xvfb has no WM;
# without one the flag is a silent no-op and the window stays un-maximized).
DISPLAY=:99 openbox &
sleep 1
# Opt-in: fetch the Widevine CDM so persistent contexts present as a real
# Chrome (a DRM/EME probe is used by some bot detectors). Off by default — only
+37 -24
View File
@@ -163,6 +163,7 @@ def launch(
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
_suppress_maximize: bool = False,
**kwargs: Any,
) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
@@ -207,11 +208,9 @@ def launch(
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
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}")
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version))
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and not _suppress_maximize)
_maybe_warn_windows_fonts(chrome_args)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -272,6 +271,7 @@ async def launch_async( # noqa: C901
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
_suppress_maximize: bool = False,
**kwargs: Any,
) -> Any:
"""Async version of launch(). Returns a Playwright Browser object.
@@ -314,10 +314,8 @@ async def launch_async( # noqa: C901
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
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 or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version))
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and not _suppress_maximize)
_maybe_warn_windows_fonts(chrome_args)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -432,9 +430,7 @@ def launch_persistent_context(
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
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}")
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
_maybe_warn_windows_fonts(chrome_args)
@@ -571,9 +567,7 @@ async def launch_persistent_context_async(
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version)
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}")
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
_maybe_warn_windows_fonts(chrome_args)
@@ -692,15 +686,16 @@ def launch_context(
# resolved values flow to binary flags
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}")
args = _append_webrtc_exit_ip(args, 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.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, extension_paths=extension_paths,
license_key=license_key, browser_version=browser_version)
license_key=license_key, browser_version=browser_version,
# Caller chose a viewport geometry → don't also auto-maximize
# the window (mirrors the persistent-context path + JS).
_suppress_maximize=(viewport is not _VIEWPORT_UNSET or "no_viewport" in kwargs))
context_kwargs: dict[str, Any] = {}
if user_agent:
@@ -815,15 +810,16 @@ async def launch_context_async(
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
# resolved values flow to binary flags
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
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}")
args = _append_webrtc_exit_ip(args, 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.
browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, extension_paths=extension_paths,
license_key=license_key, browser_version=browser_version)
license_key=license_key, browser_version=browser_version,
# Caller chose a viewport geometry → don't also auto-maximize
# the window (mirrors the persistent-context path + JS).
_suppress_maximize=(viewport is not _VIEWPORT_UNSET or "no_viewport" in kwargs))
context_kwargs: dict[str, Any] = {}
if user_agent:
@@ -1019,9 +1015,11 @@ def maybe_resolve_geoip(
# None when no proxy → echo services resolve the machine's own public IP
proxy_url = _extract_proxy_url(proxy) if proxy else None
# When both tz/locale are explicit, still resolve exit IP for WebRTC
# When both tz/locale are explicit, resolve the exit IP for WebRTC — but only
# with a proxy. With no proxy the WebRTC IP would just be the real connection
# IP the site already sees (a no-op), so skip the third-party echo call.
if timezone is not None and locale is not None:
exit_ip = resolve_proxy_exit_ip(proxy_url)
exit_ip = resolve_proxy_exit_ip(proxy_url) if proxy_url else None
return timezone, locale, exit_ip
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
@@ -1073,6 +1071,21 @@ def _resolve_webrtc_args(
return args
def _append_webrtc_exit_ip(
args: list[str] | None, exit_ip: str | None
) -> list[str] | None:
"""Append ``--fingerprint-webrtc-ip=<exit_ip>`` unless the user already set it.
*exit_ip* comes free from the geoip lookup; it spoofs the WebRTC IP to the
egress IP. No-op when there is no exit IP or the flag is already present. This
rule must stay identical across every launch path, so it lives in one place.
"""
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}")
return args
def build_args(
stealth_args: bool,
extra_args: list[str] | None,
+10 -3
View File
@@ -105,15 +105,18 @@ def resolve_proxy_geo_with_ip(
" pip install cloakbrowser[geoip]"
) from None
# Ensure the DB first — the download must NOT be bounded by the resolution
# timeout (a first-use ~70MB fetch legitimately outlasts it).
db_path = _ensure_geoip_db()
if db_path is None:
return None, None, None
timeout = _get_geoip_timeout_seconds()
deadline = _deadline_from_timeout(timeout)
# Exit IP (through proxy, or the machine's own public IP when proxy_url is
# falsy) is most accurate — gateway DNS may differ from exit
# falsy) is most accurate — gateway DNS may differ from exit. Resolved even
# when the DB is unavailable: the IP does not need the DB, and dropping it on
# a DB hiccup would let WebRTC fall back to the real IP behind a proxy while
# the connection shows the proxy IP — a real deanonymization.
ip = _resolve_exit_ip(proxy_url, timeout=_remaining_seconds(deadline))
# Hostname fallback only applies to a proxy; no proxy → echo services only
if ip is None and proxy_url and not _deadline_expired(deadline):
@@ -123,6 +126,10 @@ def resolve_proxy_geo_with_ip(
logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout)
return None, None, None
# DB only drives tz/locale; a missing/failed DB still returns the exit IP.
if db_path is None:
return None, None, ip
try:
with geoip2.database.Reader(str(db_path)) as reader:
resp = reader.city(ip)
+11 -3
View File
@@ -33,7 +33,8 @@ public static class CloakLauncher
var combined = new List<string>(args ?? new List<string>());
combined.AddRange(proxyResolution.ExtraArgs);
var chromeArgs = BuildArgs(options.StealthArgs, combined, timezone, locale, options.Headless, options.ExtensionPaths,
startMaximized: Config.BinarySupportsMaximizedWindow(options.LicenseKey, options.BrowserVersion));
startMaximized: Config.BinarySupportsMaximizedWindow(options.LicenseKey, options.BrowserVersion)
&& !options.SuppressMaximize);
MaybeWarnWindowsFonts(chromeArgs);
CloakLog.Debug($"Launching stealth Chromium (headless={options.Headless}, args={chromeArgs.Count})");
@@ -97,6 +98,9 @@ public static class CloakLauncher
BrowserVersion = options.BrowserVersion,
// geoip already resolved above; don't re-resolve.
GeoIp = false,
// Caller chose a viewport geometry → don't also auto-maximize the
// window (mirrors the persistent-context path + Python/JS).
SuppressMaximize = options.Viewport != null || options.NoViewport,
}).ConfigureAwait(false);
try
@@ -196,10 +200,14 @@ public static class CloakLauncher
// null when no proxy -> echo services resolve the machine's own public IP.
string? proxyUrl = proxy != null ? ProxyResolver.ExtractProxyUrl(proxy) : null;
// When both tz/locale are explicit, still resolve the exit IP for WebRTC.
// When both tz/locale are explicit, resolve the exit IP for WebRTC — but only
// with a proxy. With no proxy the WebRTC IP would just be the real connection
// IP the site already sees (a no-op), so skip the third-party echo call.
if (timezone != null && locale != null)
{
string? exitIpOnly = await GeoIp.ResolveProxyExitIpAsync(proxyUrl).ConfigureAwait(false);
string? exitIpOnly = proxyUrl != null
? await GeoIp.ResolveProxyExitIpAsync(proxyUrl).ConfigureAwait(false)
: null;
return (timezone, locale, exitIpOnly);
}
+10 -3
View File
@@ -93,15 +93,18 @@ public static class GeoIp
public static async Task<(string? Timezone, string? Locale, string? ExitIp)> ResolveProxyGeoWithIpAsync(
string? proxyUrl, CancellationToken ct = default)
{
// Ensure the DB first - the download must NOT be bounded by the resolution
// timeout (a first-use ~70MB fetch legitimately outlasts it).
var dbPath = await EnsureGeoIpDbAsync(ct).ConfigureAwait(false);
if (dbPath == null)
return (null, null, null);
var timeout = GetGeoIpTimeoutSeconds();
var deadline = DeadlineFromTimeout(timeout);
// Exit IP (through proxy, or the machine's own public IP when proxyUrl is
// null/empty) is most accurate - gateway DNS may differ from exit.
// null/empty) is most accurate - gateway DNS may differ from exit. Resolved
// even when the DB is unavailable: the IP does not need the DB, and dropping
// it on a DB hiccup would let WebRTC fall back to the real IP behind a proxy
// while the connection shows the proxy IP - a real deanonymization.
var ip = await ResolveExitIpAsync(proxyUrl, RemainingSeconds(deadline), ct).ConfigureAwait(false);
// Hostname fallback only applies to a proxy; no proxy -> echo services only.
if (ip == null && !string.IsNullOrEmpty(proxyUrl) && !DeadlineExpired(deadline))
@@ -113,6 +116,10 @@ public static class GeoIp
return (null, null, null);
}
// DB only drives tz/locale; a missing/failed DB still returns the exit IP.
if (dbPath == null)
return (null, null, ip);
try
{
using var reader = new DatabaseReader(dbPath);
+7
View File
@@ -56,6 +56,13 @@ public class LaunchOptions
/// pinned one. Port of Python/JS <c>browser_version</c> / <c>browserVersion</c>.
/// </summary>
public string? BrowserVersion { get; set; }
/// <summary>
/// Internal: suppress the auto <c>--start-maximized</c> flag. Set by the context
/// launchers when the caller chose a viewport geometry, so the window is not also
/// maximized. Mirrors Python <c>_suppress_maximize</c> / JS <c>explicitViewport</c>.
/// </summary>
internal bool SuppressMaximize { get; set; }
}
/// <summary>Options for context-producing launchers (adds context-level emulation settings).</summary>
@@ -24,6 +24,32 @@ public class GeoIpTests
{
Assert.True(GeoIp.CountryLocaleMap.ContainsKey("US"));
Assert.True(GeoIp.CountryLocaleMap.ContainsKey("DE"));
// Extended-coverage entries (parity with Python/JS COUNTRY_LOCALE_MAP).
Assert.Equal("sl-SI", GeoIp.CountryLocaleMap["SI"]);
Assert.Equal("es-PE", GeoIp.CountryLocaleMap["PE"]);
}
[Fact]
public async Task MaybeResolveGeoIp_Disabled_ReturnsInputWithoutResolving()
{
var (tz, loc, ip) = await CloakLauncher.MaybeResolveGeoIpAsync(
geoip: false, proxy: "http://proxy:8080", timezone: null, locale: null);
Assert.Null(tz);
Assert.Null(loc);
Assert.Null(ip);
}
[Fact]
public async Task MaybeResolveGeoIp_NoProxy_BothExplicit_SkipsExitIpFetch()
{
// No proxy + explicit tz/locale → the WebRTC IP would just be the real
// connection IP the site already sees (a no-op), so no echo call is made
// and the exit IP is null. Hermetic: this path touches no network.
var (tz, loc, ip) = await CloakLauncher.MaybeResolveGeoIpAsync(
geoip: true, proxy: null, timezone: "Europe/Berlin", locale: "de-DE");
Assert.Equal("Europe/Berlin", tz);
Assert.Equal("de-DE", loc);
Assert.Null(ip);
}
}
+29 -14
View File
@@ -91,14 +91,18 @@ export async function resolveProxyGeo(
);
}
// Ensure the DB first — the download must NOT be bounded by the resolution
// timeout (a first-use ~70MB fetch legitimately outlasts it).
const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: null, exitIp: null };
const timeoutMs = getGeoipTimeoutMs();
const deadline = deadlineFromTimeout(timeoutMs);
// Exit IP (through proxy, or the machine's own public IP when proxyUrl is
// falsy) is most accurate — gateway DNS may differ from exit
// falsy) is most accurate — gateway DNS may differ from exit. Resolved even
// when the DB is unavailable: the IP does not need the DB, and dropping it on
// a DB hiccup would let WebRTC fall back to the real IP behind a proxy while
// the connection shows the proxy IP — a real deanonymization.
let ip = await resolveExitIp(proxyUrl, remainingMs(deadline));
// Hostname fallback only applies to a proxy; no proxy → echo services only
if (!ip && proxyUrl && !deadlineExpired(deadline)) ip = await resolveProxyIp(proxyUrl);
@@ -109,6 +113,9 @@ export async function resolveProxyGeo(
return { timezone: null, locale: null, exitIp: null };
}
// DB only drives tz/locale; a missing/failed DB still returns the exit IP.
if (!dbPath) return { timezone: null, locale: null, exitIp: ip };
try {
const buf = fs.readFileSync(dbPath);
const reader = new Reader(buf);
@@ -171,17 +178,6 @@ export async function resolveProxyIp(
}
}
function isPrivateIp(ip: string): boolean {
// Quick check for common private ranges
if (ip.startsWith("10.") || ip.startsWith("127.") || ip === "::1") return true;
if (ip.startsWith("172.")) {
const second = parseInt(ip.split(".")[1], 10);
if (second >= 16 && second <= 31) return true;
}
if (ip.startsWith("192.168.")) return true;
return false;
}
const IP_ECHO_URLS = [
"https://api.ipify.org",
"https://checkip.amazonaws.com",
@@ -423,8 +419,11 @@ export async function maybeResolveGeoip(
// null when no proxy → echo services resolve the machine's own public IP
const proxyUrl = options.proxy ? extractProxyUrl(options.proxy) : null;
// When both tz/locale are explicit, still resolve exit IP for WebRTC
// When both tz/locale are explicit, resolve the exit IP for WebRTC — but only
// with a proxy. With no proxy the WebRTC IP would just be the real connection
// IP the site already sees (a no-op), so skip the third-party echo call.
if (options.timezone && options.locale) {
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const timeoutMs = getGeoipTimeoutMs();
const exitIp = await resolveExitIp(proxyUrl, timeoutMs) ?? undefined;
return { timezone: options.timezone, locale: options.locale, exitIp };
@@ -439,6 +438,22 @@ export async function maybeResolveGeoip(
};
}
/**
* Append `--fingerprint-webrtc-ip=<exitIp>` unless the user already set the flag.
* The exit IP comes free from the geoip lookup; it spoofs the WebRTC IP to the
* egress IP. No-op when there is no exit IP or the flag is already present. This
* rule must stay identical across every launch path, so it lives in one place.
*/
export function appendWebrtcExitIp(
args: string[] | undefined,
exitIp: string | undefined,
): string[] | undefined {
if (exitIp && !(args ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
return [...(args ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
return args;
}
/**
* Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
* Returns args unchanged if no ``auto`` value is present.
+4 -10
View File
@@ -14,7 +14,7 @@ import { buildArgs } from "./args.js";
import { maybeWarnWindowsFonts } from "./fonts.js";
import { ensureBinary } from "./download.js";
import { resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
import { maybeResolveGeoip, resolveWebrtcArgs, appendWebrtcExitIp } from "./geoip.js";
import { buildLaunchEnv } from "./license.js";
import { seedWidevineHint } from "./widevine.js";
@@ -116,9 +116,7 @@ export async function buildLaunchOptions(
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion);
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
maybeWarnWindowsFonts(args);
@@ -230,9 +228,7 @@ export async function launchContext(
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}`];
}
launchArgs = appendWebrtcExitIp(launchArgs, exitIp);
// --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.
@@ -302,9 +298,7 @@ export async function launchPersistentContext(
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion);
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
maybeWarnWindowsFonts(args);
+2 -4
View File
@@ -15,7 +15,7 @@ import { buildArgs } from "./args.js";
import { maybeWarnWindowsFonts } from "./fonts.js";
import { ensureBinary } from "./download.js";
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
import { maybeResolveGeoip, resolveWebrtcArgs, appendWebrtcExitIp } from "./geoip.js";
import { buildLaunchEnv } from "./license.js";
import { seedWidevineHint } from "./widevine.js";
@@ -51,9 +51,7 @@ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
maybeWarnWindowsFonts(args);
return { binaryPath, args };
+5 -7
View File
@@ -74,9 +74,9 @@ describe("maybeResolveGeoip", () => {
expect(fetchSpy.mock.calls[0][1]).toEqual({ redirect: "follow" });
});
it("no proxy: resolves the machine's own public IP directly for WebRTC", async () => {
// Both tz/locale explicit → maybeResolveGeoip resolves exit IP only,
// via a direct fetch to the echo services (no proxy).
it("no proxy + both explicit: skips the exit-IP fetch entirely", async () => {
// With no proxy the WebRTC IP would just be the real connection IP the site
// already sees (a no-op), so maybeResolveGeoip must not call the echo services.
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
text: async () => "5.6.7.8",
@@ -88,10 +88,8 @@ describe("maybeResolveGeoip", () => {
locale: "de-DE",
});
expect(result).toEqual({ timezone: "Europe/Berlin", locale: "de-DE", exitIp: "5.6.7.8" });
// Direct fetch to an echo service, not a proxied request
expect(fetchSpy).toHaveBeenCalled();
expect(fetchSpy.mock.calls[0][0]).toBe("https://api.ipify.org");
expect(result).toEqual({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
+1
View File
@@ -15,6 +15,7 @@ vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
appendWebrtcExitIp: vi.fn((args: any) => args),
}));
describe("puppeteer launch", () => {
+1
View File
@@ -15,6 +15,7 @@ vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
appendWebrtcExitIp: vi.fn((args: any) => args),
}));
vi.mock("playwright-core", () => ({ chromium: { launchPersistentContext: vi.fn() } }));
vi.mock("puppeteer-core", () => ({ default: { launch: vi.fn() } }));
+23 -4
View File
@@ -93,6 +93,21 @@ def test_resolve_geo_returns_none_when_db_missing():
assert resolve_proxy_geo("http://10.50.96.5:8888") == (None, None)
def test_resolve_geo_keeps_exit_ip_when_db_missing():
"""DB missing but IP resolvable → still return the exit IP for WebRTC spoofing.
Resolving the egress IP does not need the GeoIP DB, so a DB download failure
must not drop it — otherwise WebRTC could fall back to the real IP behind a
proxy while the connection shows the proxy IP (a deanonymization).
"""
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": None})})()
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=None):
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="9.8.7.6"):
from cloakbrowser.geoip import resolve_proxy_geo_with_ip
assert resolve_proxy_geo_with_ip("http://10.50.96.5:8888") == (None, None, "9.8.7.6")
# ---------------------------------------------------------------------------
# _resolve_exit_ip direct (no-proxy) fetch
# ---------------------------------------------------------------------------
@@ -136,16 +151,20 @@ def test_maybe_resolve_no_proxy_uses_machine_ip():
assert ip == "5.6.7.8" # drives --fingerprint-webrtc-ip
def test_maybe_resolve_no_proxy_both_explicit_resolves_ip():
"""No proxy + explicit tz/locale still resolves machine IP for WebRTC."""
def test_maybe_resolve_no_proxy_both_explicit_skips_ip():
"""No proxy + explicit tz/locale → skip the exit-IP fetch entirely.
With no proxy the WebRTC IP would just be the real connection IP the site
already sees (a no-op), so we don't make a third-party echo call.
"""
with patch(
"cloakbrowser.geoip.resolve_proxy_exit_ip", return_value="5.6.7.8"
) as m:
tz, loc, ip = maybe_resolve_geoip(True, None, "Europe/Berlin", "de-DE")
m.assert_called_once_with(None)
m.assert_not_called()
assert tz == "Europe/Berlin"
assert loc == "de-DE"
assert ip == "5.6.7.8"
assert ip is None
def test_maybe_resolve_skips_when_both_explicit():
+39
View File
@@ -124,6 +124,45 @@ def test_custom_viewport(mock_launch, _mock_bin):
assert ctx_kwargs[1]["viewport"] == custom
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_default_does_not_suppress_maximize(mock_launch, _mock_bin):
"""No explicit viewport → let launch() auto-maximize (parity with JS/.NET)."""
browser, _ = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context()
assert mock_launch.call_args.kwargs["_suppress_maximize"] is False
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_explicit_viewport_suppresses_maximize(mock_launch, _mock_bin):
"""Caller chose a viewport → suppress auto --start-maximized (parity with JS/.NET)."""
browser, _ = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(viewport={"width": 800, "height": 600})
assert mock_launch.call_args.kwargs["_suppress_maximize"] is True
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_no_viewport_kwarg_suppresses_maximize(mock_launch, _mock_bin):
"""Explicit no_viewport also counts as a chosen geometry → suppress."""
browser, _ = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(no_viewport=True)
assert mock_launch.call_args.kwargs["_suppress_maximize"] is True
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_user_agent(mock_launch, _mock_bin):
+10 -4
View File
@@ -130,11 +130,17 @@ class TestMaybeResolveGeoip:
assert locale is None
assert ip is None
def test_geoip_no_proxy_skips_resolution(self):
@patch(
"cloakbrowser.geoip.resolve_proxy_geo_with_ip",
return_value=("America/New_York", "en-US", "1.2.3.4"),
)
def test_geoip_no_proxy_uses_machine_ip(self, mock_geo):
# No proxy → resolve the machine's own public IP (proxy_url=None).
tz, locale, ip = maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert locale is None
assert ip is None
mock_geo.assert_called_once_with(None)
assert tz == "America/New_York"
assert locale == "en-US"
assert ip == "1.2.3.4"
@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):