mirror of
https://github.com/daijro/camoufox.git
synced 2026-09-08 16:01:00 +00:00
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.
188 lines
6.1 KiB
Python
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)
|