mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-10-18 23:07:13 -05:00
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:
16 files changed
+225
-78
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user