Files
Jake Writer 0e4151f820 fix: page-recycle hang under spoofed window dims, and the unmerged halves of #637-#647
Fixes the new_page() hang from #666, and restores the pythonlib/ + settings/
halves of #637-#647 that were dropped when those PRs were consolidated into #666
(that PR only carried patches/ + additions/, so these never actually landed).

## new_page() hangs when window.outer* is spoofed (#666)

The outer-size hijack in browser-init.patch pinned the chrome documentElement to
the spoofed size. That caps .browserStack, which caps the content viewport, so
the content window can never reach the size Juggler asks for in
updateViewportSize() -- and awaitViewportDimensions awaits exact equality with
no timeout, so it deadlocks rather than erroring. The second new_page() hung
forever and took the context with it.

The pin was never load-bearing: GetOuterWidth/GetOuterHeight already consult
MaskConfig unconditionally (fingerprint-injection.patch), so window.outerWidth is
spoofed in C++ regardless of the real chrome window size. Resizing is enough.

Measured on the official v152.0.4-beta.26 build (headless):

    config      before        after
    none        pass          pass
    inner       pass          pass
    outer       HANG          pass
    both        HANG          pass  (iw:360 ih:740 ow:360 oh:800 -- exact)

This corrects the diagnosis in #666, which blamed the inner+outer combination and
the `!(outerWidth || outerHeight)` guard. outer* ALONE is sufficient to hang, and
dropping inner* does not help, so that guard is not the culprit.

Also fixed driver-side: Playwright's implicit 1280x720 viewport is what asks for
the impossible size, so the driver now defaults to no_viewport when the config
spoofs any window dimension. That fixes the hang on already-released builds
without a rebuild. An explicit viewport=/no_viewport= from the caller wins.

## WebRTC ICE prefs (#538)

#666 merged the C++ half of the WebRTC fix but not the prefs, so the shipped
build still has no_host=true and none of the proxy_only prefs.
proxy_only_if_behind_proxy is the pref that actually stops the real-IP leak: it
prevents a UDP STUN request routing around a TCP proxy. no_host=false keeps the
stock two-candidate shape, which obfuscate_host_addresses makes leak-free.

## Also restored from the consolidation

- fix(proxy): dom.security.https_first rewrote http:// before the launch-arg
  proxy filter saw it, breaking CONNECT-only proxies (#638).
- fix(stealth): speech-voice spoofing + stop leaking host voices (#646).
- fix(stealth): clamp inner <= outer <= avail <= screen; BrowserForge can emit
  impossible geometries that leak as tells (#647).

Refs: https://github.com/daijro/camoufox/pull/666
Refs: https://github.com/daijro/camoufox/issues/538
2026-07-16 14:38:39 -06:00

104 lines
3.4 KiB
Python

"""
Tests for camoufox.fingerprints speech-voice generation.
Mirrors camoufox-js/tests-camoufox-js/voices.test.ts.
Run with:
cd pythonlib && python -m pytest tests/test_voices.py -v
The core regression these guard: every spoofable OS -- including Linux --
must yield a non-empty list of MaskConfig voice OBJECTS (not raw
"Name:lang:type" strings), or the C++ MaskConfig::MVoices() silently drops
them and the host machine's native voices leak through.
"""
import os
import sys
import pytest
# Make `import camoufox` resolve to the in-tree pythonlib without an install.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from camoufox.fingerprints import ( # noqa: E402
_generate_random_voice_subset,
_normalize_preset_voices,
)
_REQUIRED_FIELDS = {"lang", "name", "voiceUri", "isDefault", "isLocalService"}
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
def test_non_empty_for_every_os(target_os):
voices = _generate_random_voice_subset(target_os, "en-US")
assert len(voices) > 0
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
def test_entries_are_full_objects(target_os):
# MaskConfig::MVoices() drops any entry missing a field, so every voice
# must carry the full object shape.
for v in _generate_random_voice_subset(target_os, "en-US"):
assert isinstance(v, dict)
assert _REQUIRED_FIELDS <= set(v.keys())
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
def test_exactly_one_default(target_os):
voices = _generate_random_voice_subset(target_os, "en-US")
assert sum(1 for v in voices if v["isDefault"]) == 1
def test_default_matches_spoofed_locale_prefix():
de = _generate_random_voice_subset("linux", "de-DE")
default = next(v for v in de if v["isDefault"])
assert default["lang"].split("-")[0] == "de"
class TestLinuxSpeechdUris:
"""Linux voiceUris must match Firefox's SpeechDispatcherService.cpp:
urn:moz-tts:speechd:<NS_EscapeURL(name, OnlyNonASCII|Spaces)>?<lang>
"""
def setup_method(self):
self.lin = _generate_random_voice_subset("linux", "en-US")
def test_prefix_and_lang_suffix(self):
for v in self.lin:
assert v["voiceUri"].startswith("urn:moz-tts:speechd:")
assert v["voiceUri"].endswith("?" + v["lang"])
def test_spaces_escaped_punctuation_intact(self):
gb = next(v for v in self.lin if v["name"] == "English (Great Britain)")
assert gb["voiceUri"] == "urn:moz-tts:speechd:English%20(Great%20Britain)?en-GB"
def test_all_local_service(self):
assert all(v["isLocalService"] for v in self.lin)
def test_normalize_preset_voices_converts_strings():
# Presets historically store "Name:lang:type" strings.
out = _normalize_preset_voices(
["Albert:en-US:local", "Alice:it-IT:local"], "macos"
)
assert all(_REQUIRED_FIELDS <= set(v.keys()) for v in out)
assert out[0]["name"] == "Albert"
assert out[0]["lang"] == "en-US"
assert sum(1 for v in out if v["isDefault"]) == 1
def test_normalize_preset_voices_passes_through_objects():
obj = {
"name": "Alex",
"lang": "en-US",
"voiceUri": "urn:moz-tts:osx:alex",
"isDefault": True,
"isLocalService": True,
}
out = _normalize_preset_voices([obj], "macos")
assert out == [obj]
def test_unknown_os_falls_back_to_macos():
assert len(_generate_random_voice_subset("plan9", "en-US")) > 0