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

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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:

View file

@ -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 `<uuid>.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).

View file

@ -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)

View file

@ -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",

View file

@ -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"

View file

@ -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

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
# ──────────────────────────────────────────────────────────────────────────
# 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.
# ──────────────────────────────────────────────────────────────────────────