Files
camoufox/pythonlib/tests/test_voices.py
T
Jake Writer 160c806ad1 fix(stealth): make spoofed speech voices fail closed (#731)
Firefox registers the host's speech-dispatcher / SAPI / NSSpeech voices
unless something stops it, and nsSynthVoiceRegistry only stopped it when the
explicit `voices:blockIfNotDefined` flag was set. Nothing set that flag, so
the host was suppressed only as a side effect of a non-empty spoofed list --
and the Python layer built that list inside a bare `except Exception: pass`.

Any path that left the list empty or unset therefore fell through to the host
backend. On a stock Linux box that exposes 14805 espeak-ng voices to the page
under a fingerprint claiming macOS or Windows, which both leaks the real host
OS and contradicts the rest of the profile. Reproduced on 152.0.4-beta.29:

    config                     voices exposed
    generation raises          14805  (all host speechd)
    {"voices": []}             14805  (all host speechd)
    valid list                   115  (correct)

Three changes, so the failure is closed at both layers:

  * nsSynthVoiceRegistry::AddVoice now also blocks when MaskConfig carries a
    `voices` array at all -- including an empty one, or one whose entries were
    all rejected as malformed. An empty spoofed list must mean "no voices",
    never "all of the host's". With no `voices` key the browser still behaves
    like stock Firefox, so a bare binary is unaffected.
  * launch_options pins `voices:blockIfNotDefined` (via set_into, so an
    explicit caller value still wins) and degrades a generation failure to an
    empty list rather than leaving the key unset. It also passes the spoofed
    navigator.language through, so the default voice matches the locale.
  * validate_voices rejects the shapes MaskConfig::MVoices() silently drops --
    bare "Name:lang:type" strings and half-filled objects -- before launch
    instead of letting them degrade into a host-voice leak.

Both failure paths now expose 0 voices; the normal path still exposes 115.
2026-08-28 13:21:32 -06:00

188 lines
6.1 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
# ── Fail-closed voice configuration (daijro/camoufox#731) ────────────────────
#
# nsSynthVoiceRegistry only withholds the host's speech-dispatcher / SAPI /
# NSSpeech voices while Camoufox owns the list. An empty or unset `voices`
# used to fall through to the host backend and register every native voice --
# 14805 espeak-ng entries on a stock Linux box -- under a fingerprint claiming
# macOS or Windows.
from camoufox.exceptions import InvalidPropertyType # noqa: E402
from camoufox.utils import validate_voices # noqa: E402
def _launch_config(**kwargs):
"""Rebuild the config dict from the chunked CAMOU_CONFIG_* env vars."""
import json
from camoufox.utils import launch_options
opts = launch_options(headless=True, i_know_what_im_doing=True, **kwargs)
env = opts["env"]
chunks = sorted(
(k for k in env if k.startswith("CAMOU_CONFIG_")),
key=lambda k: int(k.rsplit("_", 1)[1]),
)
return json.loads("".join(env[k] for k in chunks))
def test_block_flag_is_pinned_by_default():
# Without this the browser has no instruction to withhold host voices when
# the list it receives turns out to be empty or unusable.
assert _launch_config(os="macos")["voices:blockIfNotDefined"] is True
def test_caller_can_override_the_block_flag():
cfg = _launch_config(os="macos", config={"voices:blockIfNotDefined": False})
assert cfg["voices:blockIfNotDefined"] is False
def test_voice_generation_failure_fails_closed(monkeypatch):
import camoufox.utils as utils
def boom(*_args, **_kwargs):
raise RuntimeError("voices.json unreadable")
monkeypatch.setattr(utils, "_generate_random_voice_subset", boom)
cfg = _launch_config(os="macos")
# An empty list plus the block flag means "no voices" -- never "all of the
# host's".
assert cfg["voices"] == []
assert cfg["voices:blockIfNotDefined"] is True
@pytest.mark.parametrize(
"value",
[
["Alex:en-US:local"], # the bare-string shape MVoices() drops
[{"name": "Alex", "lang": "en-US"}], # incomplete object
"Alex", # not a list at all
],
)
def test_validate_voices_rejects_unusable_shapes(value):
with pytest.raises(InvalidPropertyType):
validate_voices(value)
@pytest.mark.parametrize(
"value",
[
[], # empty is legal: it means "no voices", and the flag enforces it
[
{
"lang": "en-US",
"name": "Alex",
"voiceUri": "urn:moz-tts:osx:alex",
"isDefault": True,
"isLocalService": True,
}
],
],
)
def test_validate_voices_accepts_usable_shapes(value):
validate_voices(value)