fix(webrtc): ship the validated proxy realness config + CI guards

Audit follow-up (2026-06-10), all validated before commit.

#2 WebRTC — the shipped baseline now MATCHES the manually-validated config
(behind a residential proxy: host=<uuid>.local, srflx=proxy egress, No-Leak,
gathering completes, indistinguishable from vanilla Firefox on BrowserLeaks +
CreepJS):
  - prefs baseline obfuscate_host_addresses False->True; add
    zoom.stealth.webrtc.disable_ipv6=True; drop the dead
    media.peerconnection.ice.disableIPv6 (no-op on FF150)
  - launcher auto-derives the proxy egress IP via _geo.prepare_session_geo
    (one round-trip shared with the timezone resolution) and feeds nICEr via
    STEALTHFOX_WEBRTC_PUBLIC_IP + STEALTHFOX_WEBRTC_DISABLE_IPV6 in _build_env
    (sync + async); an explicit caller env still wins. The C++ mechanisms were
    already in firefox-9 — this activates them, no rebuild.

#1 drop orphan prefs zoom.stealth.timezone + zoom.stealth.seed (read by no C++;
   the live ones are juggler.timezone.override + zoom.stealth.fpp.hw_seed).

#3 release title 'rev N' instead of 'rev firefox-N'.

CI guards (unit, leak-safe — no real proxy/creds, the kind that would have
caught this gap at zero cost):
  - shipped-baseline guard + no-orphan-prefs (test_webrtc_realness.py)
  - egress auto-derive in _build_env (test_launcher_helpers.py)
  - prepare_session_geo returns (tz, egress) (test_geo.py)
CI keeps faking 'behind a proxy' with an in-process TCP-only SOCKS5 + RFC 5737
TEST-NET IPs; real-proxy residential realness stays a LOCAL manual gate.

449 unit pass.
This commit is contained in:
feder-cr
2026-06-10 14:30:16 +02:00
parent 584ad97179
commit e524695088
10 changed files with 249 additions and 51 deletions
+3 -1
View File
@@ -377,12 +377,14 @@ jobs:
TAG="${{ github.event.inputs.release_tag }}" TAG="${{ github.event.inputs.release_tag }}"
[ -z "$TAG" ] && TAG="${GITHUB_REF_NAME}" [ -z "$TAG" ] && TAG="${GITHUB_REF_NAME}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT" 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" echo "publishing DRAFT release for tag: $TAG"
- name: Create DRAFT release with all assets - name: Create DRAFT release with all assets
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with: with:
tag_name: ${{ steps.tag.outputs.tag }} 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 draft: true
prerelease: false prerelease: false
fail_on_unmatched_files: true fail_on_unmatched_files: true
+63 -9
View File
@@ -22,7 +22,7 @@ On failure:
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
from typing import Any, Dict, Optional from typing import Any, Dict, NamedTuple, Optional
from urllib.parse import quote from urllib.parse import quote
import requests import requests
@@ -136,22 +136,76 @@ def ip_to_timezone(ip: str, mmdb_path: Any) -> str:
return tz 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( def resolve_session_timezone(
timezone: str, proxy: Optional[Dict[str, str]] timezone: str, proxy: Optional[Dict[str, str]]
) -> str: ) -> str:
"""Map the user's ``timezone`` setting to a concrete IANA zone (or ``""``). """Map the user's ``timezone`` setting to a concrete IANA zone (or ``""``).
See the module docstring for the full precedence table. ``""``/``"auto"`` Timezone-only path (no WebRTC side effects): an explicit IANA zone wins and
ALWAYS resolve from the egress IP (proxy egress if a proxy is set, else the triggers NO network call; ``""``/``"auto"`` resolve from the egress IP. The
host's own public IP). On failure: with a proxy we raise launch path uses :func:`prepare_session_geo` instead (which additionally
:class:`GeoTimezoneError` (never silently use the host TZ behind a foreign returns the egress IP for WebRTC); this standalone resolver is kept for
proxy); without a proxy we fall back to ``""`` (host TZ) so a transient third-party integrations that only want the zone. See the module docstring
lookup failure can't break the launch. for the precedence table.
""" """
tz = (timezone or "").strip() tz = (timezone or "").strip()
if tz and tz.lower() != "auto": if tz and tz.lower() != "auto":
return tz # explicit IANA wins return tz # explicit IANA wins — no egress lookup
# "" or "auto" → always resolve from the egress IP.
from .download import ensure_geoip_mmdb from .download import ensure_geoip_mmdb
proxy_set = _proxy_is_set(proxy) proxy_set = _proxy_is_set(proxy)
+21 -13
View File
@@ -9,7 +9,7 @@ from typing import Any, Dict, Optional, Union
from playwright.async_api import Browser, BrowserContext, Playwright, async_playwright from playwright.async_api import Browser, BrowserContext, Playwright, async_playwright
from ._fpforge import Profile, generate_profile 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 ._headless import make_virtual_display
from ._proxy import configure_proxy as _configure_proxy_shared from ._proxy import configure_proxy as _configure_proxy_shared
from .download import ensure_binary from .download import ensure_binary
@@ -73,16 +73,20 @@ class InvisiblePlaywright:
self._browser: Optional[Browser] = None self._browser: Optional[Browser] = None
self._persistent_context: Optional[BrowserContext] = None self._persistent_context: Optional[BrowserContext] = None
self._virtual_display: Any = 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]: async def __aenter__(self) -> Union[Browser, BrowserContext]:
import sys as _sys import sys as _sys
# Resolve timezone="auto" (and the proxy-set-but-unset default) to a # Resolve timezone="auto" AND discover the proxy egress IP in one
# concrete IANA zone before anything reads self._timezone. Run the # round-trip, off the event loop, before anything reads self._timezone
# blocking geo lookup off the event loop. Fail-early if a proxy is set # or builds prefs/env. Fail-early if a proxy is set but the egress
# but the egress zone can't be resolved. # can't be resolved.
self._timezone = await asyncio.to_thread( _geo = await asyncio.to_thread(
resolve_session_timezone, self._timezone, self._proxy 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() executable = self._binary_path or ensure_binary()
prefs = translate_profile_to_prefs( prefs = translate_profile_to_prefs(
self._profile, self._profile,
@@ -203,12 +207,16 @@ class InvisiblePlaywright:
env = _os.environ.copy() env = _os.environ.copy()
if self._timezone: if self._timezone:
env["TZ"] = _tz_env(self._timezone) env["TZ"] = _tz_env(self._timezone)
# Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read # WebRTC srflx override: feed nICEr's nr_stealth_bridge the proxy egress
# by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate # IP (caller's explicit env var wins, else the IP auto-discovered in
# matching the proxy egress IP. This avoids the StaticPref IPC # __aenter__) and drop IPv6 from gathering behind a proxy.
# propagation timing issue between parent and socket processes. webrtc_ip = (
if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"): _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP")
env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["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 return env
def _resolve_headless(self) -> bool: def _resolve_headless(self) -> bool:
+24 -10
View File
@@ -8,7 +8,7 @@ from typing import Any, Dict, Optional, Union
from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwright from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwright
from ._fpforge import Profile, generate_profile 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 ._headless import make_virtual_display
from ._proxy import configure_proxy as _configure_proxy_shared from ._proxy import configure_proxy as _configure_proxy_shared
from .download import ensure_binary from .download import ensure_binary
@@ -183,12 +183,19 @@ class InvisiblePlaywright:
self._browser: Optional[Browser] = None self._browser: Optional[Browser] = None
self._persistent_context: Optional[BrowserContext] = None self._persistent_context: Optional[BrowserContext] = None
self._virtual_display: Any = 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]: def __enter__(self) -> Union[Browser, BrowserContext]:
# Resolve timezone="auto" (and the proxy-set-but-unset default) to a # Resolve timezone="auto" (and the proxy-set-but-unset default) to a
# concrete IANA zone before anything reads self._timezone. Fail-early # concrete IANA zone AND discover the proxy egress IP — one round-trip,
# if a proxy is set but the egress zone can't be resolved. # before anything reads self._timezone or builds prefs/env. Fail-early
self._timezone = resolve_session_timezone(self._timezone, self._proxy) # 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() executable = self._binary_path or ensure_binary()
prefs = self._build_prefs() prefs = self._build_prefs()
playwright_proxy = _configure_proxy_shared(self._proxy, prefs) playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
@@ -354,12 +361,19 @@ class InvisiblePlaywright:
env = _os.environ.copy() env = _os.environ.copy()
if self._timezone: if self._timezone:
env["TZ"] = _tz_env(self._timezone) env["TZ"] = _tz_env(self._timezone)
# Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read # WebRTC srflx override: feed nICEr's nr_stealth_bridge the proxy egress
# by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate # IP so the srflx candidate matches the proxy (not the real host the
# matching the proxy egress IP. This avoids the StaticPref IPC # UDP STUN would otherwise leak). An explicit env var set by the caller
# propagation timing issue between parent and socket processes. # wins; otherwise we use the egress IP auto-discovered in __enter__.
if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"): # Behind a proxy we also drop IPv6 from gathering (the disableIPv6 pref
env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["STEALTHFOX_WEBRTC_PUBLIC_IP"] # 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 return env
def _resolve_headless(self) -> bool: def _resolve_headless(self) -> bool:
+20 -9
View File
@@ -208,15 +208,21 @@ _BASELINE: Dict[str, Any] = {
"privacy.fingerprintingProtection.pbmode": False, "privacy.fingerprintingProtection.pbmode": False,
"privacy.fingerprintingProtection.remoteOverrides.enabled": False, "privacy.fingerprintingProtection.remoteOverrides.enabled": False,
# WebRTC: enabled, no public IP leak. # WebRTC: enabled, looks like a real Firefox behind NAT, no real-IP leak.
# obfuscate_host_addresses=false: our C++ injection handles candidate # obfuscate_host_addresses=true → host candidate is `<uuid>.local` mDNS,
# selection; mDNS causes mDNS-IPC to hang in sandboxed content processes. # exactly like vanilla Firefox (BrowserLeaks "No Leak", Local IP "-").
# disableIPv6=true keeps IPv6 out of gathering (less entropy, no IPv6 leak). # 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.enabled": True,
"media.peerconnection.ice.no_host": False, "media.peerconnection.ice.no_host": False,
"media.peerconnection.ice.default_address_only": False, "media.peerconnection.ice.default_address_only": False,
"media.peerconnection.ice.obfuscate_host_addresses": False, "media.peerconnection.ice.obfuscate_host_addresses": True,
"media.peerconnection.ice.disableIPv6": True, "zoom.stealth.webrtc.disable_ipv6": True,
"media.peerconnection.ice.proxy_only": False, "media.peerconnection.ice.proxy_only": False,
"media.peerconnection.ice.relay_only": False, "media.peerconnection.ice.relay_only": False,
"media.peerconnection.use_document_iceservers": True, "media.peerconnection.use_document_iceservers": True,
@@ -551,12 +557,17 @@ def translate_profile_to_prefs(
prefs["privacy.spoof_english"] = 0 prefs["privacy.spoof_english"] = 0
if timezone: 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 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.fpp.hw_seed"] = profile.seed
prefs["zoom.stealth.seed"] = profile.seed
# Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5 # Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5
# proxy suppresses all local addresses so Firefox can't gather host cands). # proxy suppresses all local addresses so Firefox can't gather host cands).
+37
View File
@@ -16,6 +16,7 @@ from invisible_playwright._geo import (
_proxy_is_set, _proxy_is_set,
discover_egress_ip, discover_egress_ip,
ip_to_timezone, ip_to_timezone,
prepare_session_geo,
resolve_session_timezone, resolve_session_timezone,
) )
@@ -286,3 +287,39 @@ def test_resolve_proxy_failure_raises(monkeypatch):
resolve_session_timezone("auto", SOCKS) resolve_session_timezone("auto", SOCKS)
with pytest.raises(GeoTimezoneError): with pytest.raises(GeoTimezoneError):
resolve_session_timezone("", SOCKS) 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)
-1
View File
@@ -48,7 +48,6 @@ _REQUIRED_PREFS_KEYS = (
"intl.accept_languages", "intl.accept_languages",
"general.useragent.locale", "general.useragent.locale",
"intl.locale.requested", "intl.locale.requested",
"zoom.stealth.seed",
"zoom.stealth.fpp.hw_seed", "zoom.stealth.fpp.hw_seed",
"zoom.stealth.webrtc.host_ip", "zoom.stealth.webrtc.host_ip",
"zoom.stealth.webgl.renderer", "zoom.stealth.webgl.renderer",
+35
View File
@@ -169,3 +169,38 @@ def test_default_context_includes_locale_when_set():
def test_default_context_omits_locale_when_empty(): def test_default_context_omits_locale_when_empty():
ip = InvisiblePlaywright(seed=42, locale="") ip = InvisiblePlaywright(seed=42, locale="")
assert "locale" not in ip._default_context_kwargs() 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"
+9 -8
View File
@@ -158,21 +158,22 @@ def test_webgl_extensions_cleared_on_windows(monkeypatch):
@pytest.mark.unit @pytest.mark.unit
def test_timezone_set_propagates_to_both_keys(): def test_timezone_set_uses_juggler_pref():
# TZ1 # 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) p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p, timezone="America/New_York") 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 prefs["juggler.timezone.override"] == "America/New_York"
assert "zoom.stealth.timezone" not in prefs
@pytest.mark.unit @pytest.mark.unit
def test_timezone_empty_omits_both_keys(): def test_timezone_empty_omits_the_key():
# TZ2 # TZ2
p = generate_profile(seed=42) p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p, timezone="") prefs = translate_profile_to_prefs(p, timezone="")
assert "zoom.stealth.timezone" not in prefs
assert "juggler.timezone.override" 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 @pytest.mark.unit
def test_extra_prefs_overrides_existing_key(): 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) p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p, extra_prefs={"zoom.stealth.seed": 999}) prefs = translate_profile_to_prefs(p, extra_prefs={"zoom.stealth.fpp.hw_seed": 999})
assert prefs["zoom.stealth.seed"] == 999 assert prefs["zoom.stealth.fpp.hw_seed"] == 999
@pytest.mark.unit @pytest.mark.unit
+37
View File
@@ -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 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. # Fake-proxy infrastructure for e2e: a tiny TCP-only SOCKS5 server.
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────