diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 902d374..2e79737 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -377,12 +377,14 @@ jobs: TAG="${{ github.event.inputs.release_tag }}" [ -z "$TAG" ] && TAG="${GITHUB_REF_NAME}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" + # bare revision number for the release title: firefox-9 -> 9 + echo "num=${TAG#firefox-}" >> "$GITHUB_OUTPUT" echo "publishing DRAFT release for tag: $TAG" - name: Create DRAFT release with all assets uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ steps.tag.outputs.tag }} - name: invisible_firefox (150.0.1) rev ${{ steps.tag.outputs.tag }} + name: invisible_firefox (150.0.1) rev ${{ steps.tag.outputs.num }} draft: true prerelease: false fail_on_unmatched_files: true diff --git a/src/invisible_playwright/_geo.py b/src/invisible_playwright/_geo.py index 02971e1..7423b2b 100644 --- a/src/invisible_playwright/_geo.py +++ b/src/invisible_playwright/_geo.py @@ -22,7 +22,7 @@ On failure: from __future__ import annotations import ipaddress -from typing import Any, Dict, Optional +from typing import Any, Dict, NamedTuple, Optional from urllib.parse import quote import requests @@ -136,22 +136,76 @@ def ip_to_timezone(ip: str, mmdb_path: Any) -> str: return tz +class SessionGeo(NamedTuple): + """Geo facts resolved once per session from a single egress round-trip. + + ``timezone`` follows the precedence in the module docstring. + ``egress_ip`` is the proxy egress IP (the IP the *outside world* sees) when + a proxy is set, else ``None`` — it feeds the WebRTC srflx override, which is + only meaningful behind a proxy (a direct connection's real STUN already + reports the truthful public IP, so we leave it alone). + """ + + timezone: str + egress_ip: Optional[str] + + +def prepare_session_geo( + timezone: str, proxy: Optional[Dict[str, str]] +) -> SessionGeo: + """Resolve the session timezone AND the proxy egress IP in ONE round-trip. + + The egress IP is discovered once and reused for both the timezone mapping + (when ``timezone`` is ``""``/``"auto"``) and the WebRTC public-IP override. + Timezone precedence is identical to :func:`resolve_session_timezone`; the + egress IP is best-effort for the WebRTC side (a discovery failure that the + timezone path doesn't need won't break the launch — but if the timezone + path *does* need it behind a proxy, that path still fails loudly). + """ + from .download import ensure_geoip_mmdb + + tz = (timezone or "").strip() + proxy_set = _proxy_is_set(proxy) + + # One discovery, reused below. Behind a proxy we always want the egress IP + # (for WebRTC) regardless of the timezone setting. + egress_ip: Optional[str] = None + egress_err: Optional[Exception] = None + if proxy_set: + try: + egress_ip = discover_egress_ip(proxy) + except Exception as exc: # noqa: BLE001 + egress_err = exc + + # Timezone resolution — same precedence as resolve_session_timezone. + if tz and tz.lower() != "auto": + return SessionGeo(tz, egress_ip) # explicit IANA wins + try: + ip = egress_ip if proxy_set else discover_egress_ip(None) + if ip is None: # proxy set but discovery failed above + raise egress_err or GeoTimezoneError("egress IP discovery failed") + return SessionGeo(ip_to_timezone(ip, ensure_geoip_mmdb()), egress_ip) + except Exception: + if proxy_set: + raise # fail-early behind a proxy (timezone_mismatch trap) + return SessionGeo("", None) # no proxy: host TZ is a safe fallback + + def resolve_session_timezone( timezone: str, proxy: Optional[Dict[str, str]] ) -> str: """Map the user's ``timezone`` setting to a concrete IANA zone (or ``""``). - See the module docstring for the full precedence table. ``""``/``"auto"`` - ALWAYS resolve from the egress IP (proxy egress if a proxy is set, else the - host's own public IP). On failure: with a proxy we raise - :class:`GeoTimezoneError` (never silently use the host TZ behind a foreign - proxy); without a proxy we fall back to ``""`` (host TZ) so a transient - lookup failure can't break the launch. + Timezone-only path (no WebRTC side effects): an explicit IANA zone wins and + triggers NO network call; ``""``/``"auto"`` resolve from the egress IP. The + launch path uses :func:`prepare_session_geo` instead (which additionally + returns the egress IP for WebRTC); this standalone resolver is kept for + third-party integrations that only want the zone. See the module docstring + for the precedence table. """ tz = (timezone or "").strip() if tz and tz.lower() != "auto": - return tz # explicit IANA wins - # "" or "auto" → always resolve from the egress IP. + return tz # explicit IANA wins — no egress lookup from .download import ensure_geoip_mmdb proxy_set = _proxy_is_set(proxy) diff --git a/src/invisible_playwright/async_api.py b/src/invisible_playwright/async_api.py index 70a7aeb..ce08608 100644 --- a/src/invisible_playwright/async_api.py +++ b/src/invisible_playwright/async_api.py @@ -9,7 +9,7 @@ from typing import Any, Dict, Optional, Union from playwright.async_api import Browser, BrowserContext, Playwright, async_playwright from ._fpforge import Profile, generate_profile -from ._geo import resolve_session_timezone +from ._geo import prepare_session_geo from ._headless import make_virtual_display from ._proxy import configure_proxy as _configure_proxy_shared from .download import ensure_binary @@ -73,16 +73,20 @@ class InvisiblePlaywright: self._browser: Optional[Browser] = None self._persistent_context: Optional[BrowserContext] = None self._virtual_display: Any = None + # Proxy egress IP (WebRTC srflx override); discovered in __aenter__. + self._webrtc_egress_ip: Optional[str] = None async def __aenter__(self) -> Union[Browser, BrowserContext]: import sys as _sys - # Resolve timezone="auto" (and the proxy-set-but-unset default) to a - # concrete IANA zone before anything reads self._timezone. Run the - # blocking geo lookup off the event loop. Fail-early if a proxy is set - # but the egress zone can't be resolved. - self._timezone = await asyncio.to_thread( - resolve_session_timezone, self._timezone, self._proxy + # Resolve timezone="auto" AND discover the proxy egress IP in one + # round-trip, off the event loop, before anything reads self._timezone + # or builds prefs/env. Fail-early if a proxy is set but the egress + # can't be resolved. + _geo = await asyncio.to_thread( + prepare_session_geo, self._timezone, self._proxy ) + self._timezone = _geo.timezone + self._webrtc_egress_ip = _geo.egress_ip executable = self._binary_path or ensure_binary() prefs = translate_profile_to_prefs( self._profile, @@ -203,12 +207,16 @@ class InvisiblePlaywright: env = _os.environ.copy() if self._timezone: env["TZ"] = _tz_env(self._timezone) - # Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read - # by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate - # matching the proxy egress IP. This avoids the StaticPref IPC - # propagation timing issue between parent and socket processes. - if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"): - env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["STEALTHFOX_WEBRTC_PUBLIC_IP"] + # WebRTC srflx override: feed nICEr's nr_stealth_bridge the proxy egress + # IP (caller's explicit env var wins, else the IP auto-discovered in + # __aenter__) and drop IPv6 from gathering behind a proxy. + webrtc_ip = ( + _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP") + or self._webrtc_egress_ip + ) + if webrtc_ip: + env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = webrtc_ip + env["STEALTHFOX_WEBRTC_DISABLE_IPV6"] = "1" return env def _resolve_headless(self) -> bool: diff --git a/src/invisible_playwright/launcher.py b/src/invisible_playwright/launcher.py index 15055ee..226debf 100644 --- a/src/invisible_playwright/launcher.py +++ b/src/invisible_playwright/launcher.py @@ -8,7 +8,7 @@ from typing import Any, Dict, Optional, Union from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwright from ._fpforge import Profile, generate_profile -from ._geo import resolve_session_timezone +from ._geo import prepare_session_geo from ._headless import make_virtual_display from ._proxy import configure_proxy as _configure_proxy_shared from .download import ensure_binary @@ -183,12 +183,19 @@ class InvisiblePlaywright: self._browser: Optional[Browser] = None self._persistent_context: Optional[BrowserContext] = None self._virtual_display: Any = None + # Proxy egress IP, discovered at launch (see __enter__). Feeds the + # WebRTC srflx override so the candidate matches the proxy IP, not the + # real host IP. None when no proxy is set. + self._webrtc_egress_ip: Optional[str] = None def __enter__(self) -> Union[Browser, BrowserContext]: # Resolve timezone="auto" (and the proxy-set-but-unset default) to a - # concrete IANA zone before anything reads self._timezone. Fail-early - # if a proxy is set but the egress zone can't be resolved. - self._timezone = resolve_session_timezone(self._timezone, self._proxy) + # concrete IANA zone AND discover the proxy egress IP — one round-trip, + # before anything reads self._timezone or builds prefs/env. Fail-early + # if a proxy is set but the egress can't be resolved. + _geo = prepare_session_geo(self._timezone, self._proxy) + self._timezone = _geo.timezone + self._webrtc_egress_ip = _geo.egress_ip executable = self._binary_path or ensure_binary() prefs = self._build_prefs() playwright_proxy = _configure_proxy_shared(self._proxy, prefs) @@ -354,12 +361,19 @@ class InvisiblePlaywright: env = _os.environ.copy() if self._timezone: env["TZ"] = _tz_env(self._timezone) - # Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read - # by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate - # matching the proxy egress IP. This avoids the StaticPref IPC - # propagation timing issue between parent and socket processes. - if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"): - env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["STEALTHFOX_WEBRTC_PUBLIC_IP"] + # WebRTC srflx override: feed nICEr's nr_stealth_bridge the proxy egress + # IP so the srflx candidate matches the proxy (not the real host the + # UDP STUN would otherwise leak). An explicit env var set by the caller + # wins; otherwise we use the egress IP auto-discovered in __enter__. + # Behind a proxy we also drop IPv6 from gathering (the disableIPv6 pref + # is dead on FF150 — the bridge filter is the real switch). + webrtc_ip = ( + _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP") + or self._webrtc_egress_ip + ) + if webrtc_ip: + env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = webrtc_ip + env["STEALTHFOX_WEBRTC_DISABLE_IPV6"] = "1" return env def _resolve_headless(self) -> bool: diff --git a/src/invisible_playwright/prefs.py b/src/invisible_playwright/prefs.py index 4f0a15d..34ddb2f 100644 --- a/src/invisible_playwright/prefs.py +++ b/src/invisible_playwright/prefs.py @@ -208,15 +208,21 @@ _BASELINE: Dict[str, Any] = { "privacy.fingerprintingProtection.pbmode": False, "privacy.fingerprintingProtection.remoteOverrides.enabled": False, - # WebRTC: enabled, no public IP leak. - # obfuscate_host_addresses=false: our C++ injection handles candidate - # selection; mDNS causes mDNS-IPC to hang in sandboxed content processes. - # disableIPv6=true keeps IPv6 out of gathering (less entropy, no IPv6 leak). + # WebRTC: enabled, looks like a real Firefox behind NAT, no real-IP leak. + # obfuscate_host_addresses=true → host candidate is `.local` mDNS, + # exactly like vanilla Firefox (BrowserLeaks "No Leak", Local IP "-"). + # The mDNS-IPC hang feared on older builds does NOT reproduce on FF150. + # The proxy-egress srflx is injected by our C++ (srflx swap §17 + fallback + # §17.B), fed the egress IP via STEALTHFOX_WEBRTC_PUBLIC_IP from + # launcher._build_env (auto-discovered from the proxy). + # IPv6: media.peerconnection.ice.disableIPv6 is DEAD on FF150 (read by no + # ICE-gathering code). The real switch is our zoom.stealth.webrtc.disable_ipv6 + # (nICEr addrs.cpp filter) + the STEALTHFOX_WEBRTC_DISABLE_IPV6 env. "media.peerconnection.enabled": True, "media.peerconnection.ice.no_host": False, "media.peerconnection.ice.default_address_only": False, - "media.peerconnection.ice.obfuscate_host_addresses": False, - "media.peerconnection.ice.disableIPv6": True, + "media.peerconnection.ice.obfuscate_host_addresses": True, + "zoom.stealth.webrtc.disable_ipv6": True, "media.peerconnection.ice.proxy_only": False, "media.peerconnection.ice.relay_only": False, "media.peerconnection.use_document_iceservers": True, @@ -551,12 +557,17 @@ def translate_profile_to_prefs( prefs["privacy.spoof_english"] = 0 if timezone: - prefs["zoom.stealth.timezone"] = timezone + # juggler.timezone.override is the SOLE source of truth read by the C++ + # timezone chain (BrowsingContext::Attach/DidSet, ContentChild). The old + # zoom.stealth.timezone pref was declared in the yaml but read by NO + # code — dropped here on 2026-06-10 (see 20-our-patches.md §8). prefs["juggler.timezone.override"] = timezone - # Cross-process seed (canvas noise + DWrite gamma share this). + # Cross-process seed (canvas noise + DWrite gamma share this). Only + # zoom.stealth.fpp.hw_seed is read by the C++; the old zoom.stealth.seed + # alias was never declared in the yaml and read by nothing — dropped + # 2026-06-10. prefs["zoom.stealth.fpp.hw_seed"] = profile.seed - prefs["zoom.stealth.seed"] = profile.seed # Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5 # proxy suppresses all local addresses so Firefox can't gather host cands). diff --git a/tests/test_geo.py b/tests/test_geo.py index 39ef5ee..f56322f 100644 --- a/tests/test_geo.py +++ b/tests/test_geo.py @@ -16,6 +16,7 @@ from invisible_playwright._geo import ( _proxy_is_set, discover_egress_ip, ip_to_timezone, + prepare_session_geo, resolve_session_timezone, ) @@ -286,3 +287,39 @@ def test_resolve_proxy_failure_raises(monkeypatch): resolve_session_timezone("auto", SOCKS) with pytest.raises(GeoTimezoneError): resolve_session_timezone("", SOCKS) + + +# ────────────────────────────────────────────────────────────────────── +# prepare_session_geo — one round-trip for BOTH timezone + the WebRTC +# egress IP. The egress feeds the srflx override (only behind a proxy). +# ────────────────────────────────────────────────────────────────────── +@pytest.mark.unit +def test_prepare_geo_egress_present_behind_proxy(stub_egress): + geo = prepare_session_geo("auto", SOCKS) + assert geo.timezone == "America/New_York" + assert geo.egress_ip == "203.0.113.7" # discovered for WebRTC + assert stub_egress["proxy_arg"] == SOCKS + + +@pytest.mark.unit +def test_prepare_geo_egress_present_even_with_explicit_tz(stub_egress): + # explicit IANA zone still needs the egress for WebRTC behind a proxy. + geo = prepare_session_geo("Asia/Tokyo", SOCKS) + assert geo.timezone == "Asia/Tokyo" + assert geo.egress_ip == "203.0.113.7" + assert stub_egress["called"] is True + + +@pytest.mark.unit +def test_prepare_geo_no_egress_without_proxy(stub_egress): + # no proxy → no WebRTC override (real STUN already tells the truth). + geo = prepare_session_geo("auto", None) + assert geo.timezone == "America/New_York" + assert geo.egress_ip is None + + +@pytest.mark.unit +def test_prepare_geo_timezone_matches_resolve_session_timezone(stub_egress): + # the thin tz wrapper must stay equivalent to prepare_session_geo().timezone + for tz, proxy in [("Asia/Tokyo", SOCKS), ("auto", HTTP), ("", None)]: + assert prepare_session_geo(tz, proxy).timezone == resolve_session_timezone(tz, proxy) diff --git a/tests/test_integration.py b/tests/test_integration.py index 1da7621..7abd55f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -48,7 +48,6 @@ _REQUIRED_PREFS_KEYS = ( "intl.accept_languages", "general.useragent.locale", "intl.locale.requested", - "zoom.stealth.seed", "zoom.stealth.fpp.hw_seed", "zoom.stealth.webrtc.host_ip", "zoom.stealth.webgl.renderer", diff --git a/tests/test_launcher_helpers.py b/tests/test_launcher_helpers.py index 5122e88..590736a 100644 --- a/tests/test_launcher_helpers.py +++ b/tests/test_launcher_helpers.py @@ -169,3 +169,38 @@ def test_default_context_includes_locale_when_set(): def test_default_context_omits_locale_when_empty(): ip = InvisiblePlaywright(seed=42, locale="") assert "locale" not in ip._default_context_kwargs() + + +# ── InvisiblePlaywright._build_env — WebRTC egress auto-derive ───────── +# Locks the 2026-06-10 fix: behind a proxy the launcher feeds the discovered +# egress IP to nICEr (srflx override) + drops IPv6. Without it, a proxied +# session's WebRTC silently fell back to leaking/blocking. Runs in tests.yml. + + +@pytest.mark.unit +def test_build_env_injects_webrtc_egress_when_discovered(): + ip = InvisiblePlaywright(seed=42) + ip._webrtc_egress_ip = "203.0.113.9" # what __enter__ resolves behind a proxy + env = ip._build_env() + assert env["STEALTHFOX_WEBRTC_PUBLIC_IP"] == "203.0.113.9" + assert env["STEALTHFOX_WEBRTC_DISABLE_IPV6"] == "1" + + +@pytest.mark.unit +def test_build_env_no_webrtc_keys_without_proxy(monkeypatch): + monkeypatch.delenv("STEALTHFOX_WEBRTC_PUBLIC_IP", raising=False) + ip = InvisiblePlaywright(seed=42) + ip._webrtc_egress_ip = None # no proxy → real STUN already truthful + env = ip._build_env() + assert "STEALTHFOX_WEBRTC_PUBLIC_IP" not in env + assert "STEALTHFOX_WEBRTC_DISABLE_IPV6" not in env + + +@pytest.mark.unit +def test_build_env_caller_env_override_wins(monkeypatch): + monkeypatch.setenv("STEALTHFOX_WEBRTC_PUBLIC_IP", "198.51.100.5") + ip = InvisiblePlaywright(seed=42) + ip._webrtc_egress_ip = "203.0.113.9" # auto-discovered + env = ip._build_env() + assert env["STEALTHFOX_WEBRTC_PUBLIC_IP"] == "198.51.100.5" # caller wins + assert env["STEALTHFOX_WEBRTC_DISABLE_IPV6"] == "1" diff --git a/tests/test_prefs.py b/tests/test_prefs.py index fa27345..ae088c8 100644 --- a/tests/test_prefs.py +++ b/tests/test_prefs.py @@ -158,21 +158,22 @@ def test_webgl_extensions_cleared_on_windows(monkeypatch): @pytest.mark.unit -def test_timezone_set_propagates_to_both_keys(): - # TZ1 +def test_timezone_set_uses_juggler_pref(): + # TZ1 — juggler.timezone.override is the sole C++-read timezone pref; + # the old zoom.stealth.timezone alias (orphan) must NOT be reintroduced. p = generate_profile(seed=42) prefs = translate_profile_to_prefs(p, timezone="America/New_York") - assert prefs["zoom.stealth.timezone"] == "America/New_York" assert prefs["juggler.timezone.override"] == "America/New_York" + assert "zoom.stealth.timezone" not in prefs @pytest.mark.unit -def test_timezone_empty_omits_both_keys(): +def test_timezone_empty_omits_the_key(): # TZ2 p = generate_profile(seed=42) prefs = translate_profile_to_prefs(p, timezone="") - assert "zoom.stealth.timezone" not in prefs assert "juggler.timezone.override" not in prefs + assert "zoom.stealth.timezone" not in prefs # ────────────────────────────────────────────────────────────────────── @@ -200,10 +201,10 @@ def test_extra_prefs_none_value_deletes_key(): @pytest.mark.unit def test_extra_prefs_overrides_existing_key(): - # EP3 + # EP3 — override a real baseline key (hw_seed is the live cross-process seed) p = generate_profile(seed=42) - prefs = translate_profile_to_prefs(p, extra_prefs={"zoom.stealth.seed": 999}) - assert prefs["zoom.stealth.seed"] == 999 + prefs = translate_profile_to_prefs(p, extra_prefs={"zoom.stealth.fpp.hw_seed": 999}) + assert prefs["zoom.stealth.fpp.hw_seed"] == 999 @pytest.mark.unit diff --git a/tests/test_webrtc_realness.py b/tests/test_webrtc_realness.py index afa0736..61d487f 100644 --- a/tests/test_webrtc_realness.py +++ b/tests/test_webrtc_realness.py @@ -214,6 +214,43 @@ def test_mdns_host_is_invisible_to_creep_resolver(): assert creep_get_ipaddress("v=0\r\nc=IN IP4 0.0.0.0\r\n" f"a={HOST_MDNS}\r\n") is None +# ────────────────────────────────────────────────────────────────────────── +# SHIPPED-BASELINE guard — the cheap unit test that would have caught the +# 2026-06-10 gap (baseline obfuscate=False, dead disableIPv6, orphan prefs). +# These lock the shipped wrapper config to the manually-validated one so a +# future edit / merge can't silently un-ship it. Run in tests.yml. +# ────────────────────────────────────────────────────────────────────────── +from invisible_playwright._fpforge import generate_profile # noqa: E402 +from invisible_playwright.prefs import translate_profile_to_prefs # noqa: E402 + + +@pytest.mark.unit +def test_shipped_webrtc_baseline_is_the_validated_config(): + prefs = translate_profile_to_prefs(generate_profile(seed=42)) + # host candidate must be mDNS .local like vanilla Firefox (manually + # validated on BrowserLeaks/CreepJS through a residential proxy) — not a + # raw LAN IP. + assert prefs["media.peerconnection.ice.obfuscate_host_addresses"] is True + # IPv6 dropped via OUR live filter pref; the native pref is dead on FF150 + # and must not be relied upon (or re-introduced as if it worked). + assert prefs["zoom.stealth.webrtc.disable_ipv6"] is True + assert "media.peerconnection.ice.disableIPv6" not in prefs + # peerconnection stays ON (a disabled WebRTC is itself a tell). + assert prefs["media.peerconnection.enabled"] is True + + +@pytest.mark.unit +def test_no_orphan_prefs_in_baseline(): + """zoom.stealth.timezone / zoom.stealth.seed are read by NO C++ — they must + not be written (juggler.timezone.override + zoom.stealth.fpp.hw_seed are the + real ones). Guards against re-introducing a pref the binary ignores.""" + prefs = translate_profile_to_prefs(generate_profile(seed=42), timezone="America/Chicago") + assert "zoom.stealth.timezone" not in prefs + assert "zoom.stealth.seed" not in prefs + assert prefs["juggler.timezone.override"] == "America/Chicago" + assert "zoom.stealth.fpp.hw_seed" in prefs + + # ────────────────────────────────────────────────────────────────────────── # Fake-proxy infrastructure for e2e: a tiny TCP-only SOCKS5 server. # ──────────────────────────────────────────────────────────────────────────