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
This commit is contained in:
Jake Writer
2026-07-16 14:38:39 -06:00
parent 6266d707a0
commit 0e4151f820
10 changed files with 896 additions and 43 deletions
+14 -1
View File
@@ -16,7 +16,12 @@ from typing_extensions import Literal
from camoufox.virtdisplay import VirtualDisplay
from .fingerprints import generate_context_fingerprint
from .utils import async_attach_vd, launch_options
from .utils import (
async_attach_vd,
attach_no_viewport_default,
launch_options,
spoofs_window_dimensions,
)
class AsyncCamoufox(PlaywrightContextManager):
@@ -105,13 +110,21 @@ async def AsyncNewBrowser(
partial(launch_options, headless=headless, debug=debug, **kwargs),
)
# Playwright's default viewport deadlocks Juggler when the window is spoofed
# to a different size (daijro/camoufox#666), so default to no_viewport.
no_viewport_default = spoofs_window_dimensions(from_options)
# Persistent context
if persistent_context:
if no_viewport_default and not ('viewport' in from_options or 'no_viewport' in from_options):
from_options = {**from_options, 'no_viewport': True}
context = await playwright.firefox.launch_persistent_context(**from_options)
return await async_attach_vd(context, virtual_display)
# Browser
browser = await playwright.firefox.launch(**from_options)
if no_viewport_default:
attach_no_viewport_default(browser)
return await async_attach_vd(browser, virtual_display)
+273 -31
View File
@@ -121,12 +121,17 @@ def _generate_random_font_subset(target_os: str) -> List[str]:
return result
# OS voice lists loaded from voices.json
_OS_VOICES_CACHE: Optional[Dict[str, List[str]]] = None
# OS voice lists loaded from voices.json, parsed into "Name:lang:type" tuples.
_OS_VOICES_CACHE: Optional[Dict[str, List[Tuple[str, str, str]]]] = None
def _load_os_voices() -> Dict[str, List[str]]:
"""Load OS voice lists from voices.json, extracting voice names."""
def _load_os_voices() -> Dict[str, List[Tuple[str, str, str]]]:
"""Load OS voice lists from voices.json as (name, lang, type) tuples.
Each entry is "Name:lang:type" (type is "local" or "remote"). Voice names
may contain parens/commas but not colons, so a last-two-colons split is
safe.
"""
global _OS_VOICES_CACHE
if _OS_VOICES_CACHE is not None:
return _OS_VOICES_CACHE
@@ -134,10 +139,23 @@ def _load_os_voices() -> Dict[str, List[str]]:
with open(voices_path, 'rb') as f:
import orjson
raw = orjson.loads(f.read())
# Extract voice names from "Name:locale:type" format
_OS_VOICES_CACHE = {}
for os_key, entries in raw.items():
_OS_VOICES_CACHE[os_key] = [e.split(':')[0] for e in entries]
parsed: List[Tuple[str, str, str]] = []
for entry in entries:
last = entry.rfind(':')
if last < 0:
continue
vtype = entry[last + 1:]
before = entry[:last]
langsep = before.rfind(':')
if langsep < 0:
continue
lang = before[langsep + 1:]
name = before[:langsep]
if name and lang:
parsed.append((name, lang, vtype))
_OS_VOICES_CACHE[os_key] = parsed
return _OS_VOICES_CACHE
@@ -151,13 +169,56 @@ _ESSENTIAL_VOICES_WINDOWS = [
'Microsoft Mark - English (United States)',
]
# Real Firefox speechSynthesis URI prefixes per backend.
# macOS NSSpeechSynthesizer -> "urn:moz-tts:osx:<dotted-slug>"
# Windows SAPI -> "urn:moz-tts:sapi:<dotted-slug>"
# Linux speech-dispatcher -> "urn:moz-tts:speechd:<escaped-name>?<lang>"
_VOICE_URI_PREFIX = {
'mac': 'urn:moz-tts:osx:',
'win': 'urn:moz-tts:sapi:',
'lin': 'urn:moz-tts:speechd:',
}
def _generate_random_voice_subset(target_os: str) -> List[str]:
"""
Generate a random subset of speech voices for the given OS.
macOS: random 40-80% of non-essential + essential always included.
Windows: all voices (too few to subset meaningfully).
Linux: empty list (no native speech voices).
def _voice_uri_slug(name: str) -> str:
"""Stable dotted slug for mac/win URIs (shape-plausible, not catalog-exact)."""
return re.sub(r'^\.|\.$', '', re.sub(r'[^a-z0-9]+', '.', name.lower()))
def _voice_uri(os_key: str, name: str, lang: str) -> str:
"""Build a voiceUri matching what real Firefox emits for the OS backend."""
if os_key == 'lin':
# Firefox's SpeechDispatcherService.cpp builds:
# "urn:moz-tts:speechd:" + NS_EscapeURL(name, OnlyNonASCII|Spaces) + "?" + lang
# i.e. spaces -> %20 and non-ASCII bytes -> %XX, ASCII punctuation intact.
escaped = []
for ch in name:
if ch == ' ':
escaped.append('%20')
elif ord(ch) <= 0x7F:
escaped.append(ch)
else:
escaped.append(''.join(f'%{b:02X}' for b in ch.encode('utf-8')))
return f"{_VOICE_URI_PREFIX['lin']}{''.join(escaped)}?{lang}"
return f"{_VOICE_URI_PREFIX.get(os_key, '')}{_voice_uri_slug(name)}"
def _generate_random_voice_subset(
target_os: str, locale: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Generate the speech voice list for the given OS as MaskConfig objects.
Returns a list of {lang, name, voiceUri, isDefault, isLocalService} dicts,
the shape MaskConfig::MVoices() requires (it silently drops any entry
missing a field, so raw name strings would register nothing).
Without this override, Firefox registers the HOST machine's
speech-dispatcher / SAPI / NSSpeech voices, leaking the OS the wrapper
actually runs on. We therefore emit a list for EVERY target OS:
macOS: essential voices + a random 40-80% of the rest.
Windows: full SAPI set (subsetting a fixed list reads as suspicious).
Linux: full espeak-ng base-language set (~131 voices) as enumerated
by speech-dispatcher — the fixed list a Linux Firefox exposes.
"""
os_voices_data = _load_os_voices()
os_key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}.get(target_os, 'mac')
@@ -166,27 +227,203 @@ def _generate_random_voice_subset(target_os: str) -> List[str]:
if not full_list:
return []
# Windows has too few voices to subset — return all
if target_os == 'windows':
return list(full_list)
# macOS: random 40-80% subset
essential = set(_ESSENTIAL_VOICES_MACOS)
result = [v for v in full_list if v in essential]
non_essential = [v for v in full_list if v not in essential]
pct = 40 + int(random() * 41) # 40-80%
count = round((pct / 100) * len(non_essential))
if count < len(non_essential):
selected = sample(non_essential, count)
if os_key in ('win', 'lin'):
# Fixed lists across installs (SAPI / espeak-ng) — ship the whole set.
selected = list(full_list)
else:
selected = non_essential
result.extend(selected)
# macOS: essential voices + random 40-80% of the rest.
essential = set(_ESSENTIAL_VOICES_MACOS)
result = [v for v in full_list if v[0] in essential]
non_essential = [v for v in full_list if v[0] not in essential]
pct = 40 + int(random() * 41) # 40-80%
count = round((pct / 100) * len(non_essential))
if count < len(non_essential):
result.extend(sample(non_essential, count))
else:
result.extend(non_essential)
selected = result
voices: List[Dict[str, Any]] = [
{
'name': name,
'lang': lang,
'voiceUri': _voice_uri(os_key, name, lang),
'isDefault': False,
'isLocalService': vtype == 'local',
}
for (name, lang, vtype) in selected
]
# Mark a default voice matching the spoofed locale prefix so it lines up
# with Intl.DateTimeFormat().resolvedOptions().locale (CreepJS flags a
# voiceLangMismatch otherwise).
if voices:
prefix = locale.split('-')[0].lower() if locale else 'en'
idx = next(
(i for i, v in enumerate(voices) if locale and v['lang'].lower() == locale.lower()),
-1,
)
if idx < 0:
idx = next(
(i for i, v in enumerate(voices) if v['lang'].split('-')[0].lower() == prefix),
-1,
)
if idx < 0:
idx = 0
voices[idx]['isDefault'] = True
return voices
def _normalize_preset_voices(
voices: Any, target_os: str
) -> List[Dict[str, Any]]:
"""Coerce a preset's `speechVoices` into MaskConfig voice objects.
Presets historically store voices as "Name:lang:type" strings, which the
C++ MaskConfig::MVoices() silently drops (it needs full objects). Convert
them; pass through entries that are already objects.
"""
os_key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}.get(target_os, 'mac')
result: List[Dict[str, Any]] = []
for entry in voices:
if isinstance(entry, dict):
result.append(entry)
continue
last = entry.rfind(':')
if last < 0:
continue
vtype = entry[last + 1:]
before = entry[:last]
langsep = before.rfind(':')
if langsep < 0:
continue
lang = before[langsep + 1:]
name = before[:langsep]
if not name or not lang:
continue
result.append(
{
'name': name,
'lang': lang,
'voiceUri': _voice_uri(os_key, name, lang),
'isDefault': False,
'isLocalService': vtype == 'local',
}
)
if result and not any(v['isDefault'] for v in result):
result[0]['isDefault'] = True
return result
def fix_navigator_arch(config: Dict[str, Any], target_os: str) -> None:
"""Force navigator.platform AND navigator.oscpu to match the UA's arch.
~8% of Linux Firefox fingerprints in the BrowserForge pool report
"Linux armv81" for platform/oscpu while the UA says "Linux x86_64". That
arch mismatch is itself a CreepJS lie signal (CreepJS cross-checks oscpu,
platform, and the UA arch). Mac/Windows pools are consistent and need no
correction.
"""
if target_os != 'lin':
return
ua = config.get('navigator.userAgent')
if not ua:
return
target = ''
if 'Linux x86_64' in ua:
target = 'Linux x86_64'
elif 'Linux i686' in ua:
target = 'Linux i686'
if not target:
return
if config.get('navigator.platform') != target:
config['navigator.platform'] = target
if config.get('navigator.oscpu') != target:
config['navigator.oscpu'] = target
def fix_screen_no_taskbar(config: Dict[str, Any], target_os: str) -> None:
"""Ensure screen.availHeight < screen.height so CreepJS's noTaskbar flag
(screen.height == availHeight and screen.width == availWidth) doesn't flip.
Every desktop OS keeps some chrome visible (Mac menu bar ~25px, Win taskbar
~40px, Linux panel ~27px); the BrowserForge pool occasionally ships
fingerprints with identical screen/avail values which leak as a headless
tell. Also clamp window.outerHeight (and innerHeight) to the new avail so
the window isn't taller than the available area.
"""
sw = config.get('screen.width')
sh = config.get('screen.height')
aw = config.get('screen.availWidth')
ah = config.get('screen.availHeight')
if not (sw and sh and aw == sw and ah == sh):
return
taskbar = 40 if target_os == 'win' else 25 if target_os == 'mac' else 27
new_avail = sh - taskbar
config['screen.availHeight'] = new_avail
oh = config.get('window.outerHeight')
if oh and oh > new_avail:
ih = config.get('window.innerHeight')
chrome = oh - ih if ih else 0
config['window.outerHeight'] = new_avail
if ih:
config['window.innerHeight'] = new_avail - chrome
def clamp_window_dimensions(config: Dict[str, Any]) -> None:
"""Enforce inner <= outer <= avail <= screen on BOTH axes.
The browser faithfully reports whatever we inject, so a BrowserForge
fingerprint that ships e.g. outerWidth > screen.width or innerWidth >
outerWidth leaks as an impossible geometry. Shrink each level down to its
container, preserving the chrome delta between outer and inner where
possible. Complements fix_screen_no_taskbar (which only clamps height).
"""
for axis in ('Width', 'Height'):
screen = config.get(f'screen.{axis.lower()}')
avail = config.get(f'screen.avail{axis}')
outer = config.get(f'window.outer{axis}')
inner = config.get(f'window.inner{axis}')
# avail must not exceed screen
if screen and avail and avail > screen:
config[f'screen.avail{axis}'] = screen
avail_clamped = config.get(f'screen.avail{axis}', screen)
# outer must not exceed avail (or screen if avail is unknown)
outer_cap = avail_clamped if avail_clamped is not None else screen
if outer and outer_cap and outer > outer_cap:
chrome = max(0, outer - inner) if inner else 0
config[f'window.outer{axis}'] = outer_cap
if inner:
config[f'window.inner{axis}'] = max(1, outer_cap - chrome)
# inner must not exceed outer
outer_clamped = config.get(f'window.outer{axis}', outer)
inner_now = config.get(f'window.inner{axis}')
if inner_now and outer_clamped and inner_now > outer_clamped:
config[f'window.inner{axis}'] = outer_clamped
def set_media_devices_defaults(config: Dict[str, Any]) -> None:
"""Spoof navigator.mediaDevices.enumerateDevices() so headless contexts
expose a plausible device list.
A real desktop browser without explicit mic permission reports one
audioinput + one videoinput; an empty list is a headless tell. The patched
MediaDevices::FilterExposedDevices reads mediaDevices:{enabled,micros,
webcams,speakers}. Default to one of each input kind unless the caller
already set any mediaDevices: key.
"""
if any(k.startswith('mediaDevices:') for k in config):
return
config['mediaDevices:enabled'] = True
config['mediaDevices:micros'] = 1
config['mediaDevices:webcams'] = 1
config['mediaDevices:speakers'] = 0
def _select_presets_file(ff_version: Optional[Any] = None) -> Path:
"""Pick the bundled-presets file appropriate for a given Firefox version.
@@ -346,7 +583,9 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any
config['voices'] = _generate_random_voice_subset(target_os)
except Exception:
if preset.get('speechVoices'):
config['voices'] = preset['speechVoices']
config['voices'] = _normalize_preset_voices(
preset['speechVoices'], target_os
)
return config
@@ -424,10 +663,13 @@ def _build_init_script(values: Dict[str, Any]) -> str:
f' if (typeof w.setFontList === "function") w.setFontList({_json.dumps(joined)});'
)
# Speech voices (comma-separated)
# Speech voices (comma-separated names). config['voices'] holds MaskConfig
# voice objects; extract the display name from each (tolerating a legacy
# list of plain name strings).
voices = values.get('speechVoices')
if voices and len(voices) > 0:
joined = ','.join(voices)
names = [v['name'] if isinstance(v, dict) else v for v in voices]
joined = ','.join(names)
lines.append(
f' if (typeof w.setSpeechVoices === "function") w.setSpeechVoices({_json.dumps(joined)});'
)
+14 -1
View File
@@ -14,7 +14,12 @@ from typing_extensions import Literal
from camoufox.virtdisplay import VirtualDisplay
from .fingerprints import generate_context_fingerprint
from .utils import launch_options, sync_attach_vd
from .utils import (
attach_no_viewport_default,
launch_options,
spoofs_window_dimensions,
sync_attach_vd,
)
class Camoufox(PlaywrightContextManager):
@@ -104,13 +109,21 @@ def NewBrowser(
if not from_options:
from_options = launch_options(headless=headless, debug=debug, **kwargs)
# Playwright's default viewport deadlocks Juggler when the window is spoofed
# to a different size (daijro/camoufox#666), so default to no_viewport.
no_viewport_default = spoofs_window_dimensions(from_options)
# Persistent context
if persistent_context:
if no_viewport_default and not ('viewport' in from_options or 'no_viewport' in from_options):
from_options = {**from_options, 'no_viewport': True}
context = playwright.firefox.launch_persistent_context(**from_options)
return sync_attach_vd(context, virtual_display)
# Browser
browser = playwright.firefox.launch(**from_options)
if no_viewport_default:
attach_no_viewport_default(browser)
return sync_attach_vd(browser, virtual_display)
+80 -1
View File
@@ -1,5 +1,6 @@
import os
import sys
from functools import wraps
from os import environ
from os.path import abspath
from pathlib import Path
@@ -20,7 +21,7 @@ from .exceptions import (
InvalidPropertyType,
NonFirefoxFingerprint,
)
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_screen_no_taskbar, clamp_window_dimensions, set_media_devices_defaults
from .geolocation import geoip_allowed, get_geolocation
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
from .locales import handle_locales
@@ -345,6 +346,64 @@ def warn_manual_config(config: Dict[str, Any]) -> None:
LeakWarning.warn('viewport', False)
_WINDOW_DIM_KEYS = (
'window.outerWidth',
'window.outerHeight',
'window.innerWidth',
'window.innerHeight',
'document.body.clientWidth',
'document.body.clientHeight',
)
def spoofs_window_dimensions(from_options: Dict[str, Any]) -> bool:
"""
Whether the CAMOU_CONFIG in a set of launch options spoofs any window
dimension. The config is chunked across CAMOU_CONFIG_<n> env vars, so
reassemble it in index order before looking.
"""
env = from_options.get('env') or {}
chunks = [(int(k.rsplit('_', 1)[1]), v) for k, v in env.items() if k.startswith('CAMOU_CONFIG_')]
if not chunks:
return False
blob = ''.join(v for _, v in sorted(chunks))
return any(key in blob for key in _WINDOW_DIM_KEYS)
def attach_no_viewport_default(target: Any) -> Any:
"""
Default new_page()/new_context() to no_viewport=True.
Playwright applies a 1280x720 viewport by default, which makes Juggler ask
the content window to become 1280x720 (TargetRegistry.updateViewportSize).
When Camoufox is pinning the window to a spoofed size, that request can
never be satisfied, and awaitViewportDimensions has no timeout -- so the
second new_page() hangs forever (daijro/camoufox#666).
With no_viewport, Juggler measures the window instead of resizing it, so the
handshake resolves immediately and the page reports the spoofed dimensions
exactly. Explicit viewport=/no_viewport= from the caller always wins.
"""
for name in ('new_page', 'new_context'):
original = getattr(target, name, None)
if original is None:
continue
def wrap(original: Any) -> Any:
@wraps(original)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if 'viewport' not in kwargs and 'no_viewport' not in kwargs:
kwargs['no_viewport'] = True
# Works for both sync and async: async returns the coroutine
# unawaited, and the caller awaits it as usual.
return original(*args, **kwargs)
return wrapper
setattr(target, name, wrap(original))
return target
async def async_attach_vd(
browser: Any, virtual_display: Optional[VirtualDisplay] = None
) -> Any: # type: ignore
@@ -561,6 +620,13 @@ def launch_options(
if not i_know_what_im_doing:
warn_manual_config(config)
# Snapshot which domains the USER set before fingerprint generation fills in
# the rest. The post-generation BrowserForge-correction fixes below must
# only touch generated values, never override what the user passed.
_user_set_navigator = is_domain_set(config, 'navigator.')
_user_set_screen_window = is_domain_set(config, 'screen.', 'window.')
_user_set_media_devices = is_domain_set(config, 'mediaDevices:')
# Assert the target OS is valid
if os:
check_valid_os(os)
@@ -617,6 +683,14 @@ def launch_options(
target_os = get_target_os(config)
# Correct BrowserForge fingerprint inconsistencies that leak as headless /
# impossible-geometry tells, unless the user is driving these themselves.
if not _user_set_navigator:
fix_navigator_arch(config, target_os)
if not _user_set_screen_window:
fix_screen_no_taskbar(config, target_os)
clamp_window_dimensions(config)
# Set a random window.history.length
set_into(config, 'window.history.length', randrange(1, 6)) # nosec
@@ -646,6 +720,11 @@ def launch_options(
except Exception:
pass
# Default mediaDevices to one mic + one camera so headless contexts don't
# expose an empty enumerateDevices() list (a headless tell).
if not _user_set_media_devices:
set_media_devices_defaults(config)
# Set random seeds for fingerprint noise (per launch)
set_into(config, 'fonts:spacing_seed', randint(1, 4_294_967_295)) # nosec
set_into(config, 'audio:seed', randint(1, 4_294_967_295)) # nosec
+133 -1
View File
@@ -246,5 +246,137 @@
"Microsoft Tolga - Turkish (Turkey):tr-TR:local",
"Microsoft Sarah Mobile - English (Great Britain):en-GB:local"
],
"lin": []
"lin": [
"Afrikaans:af:local",
"Amharic:am:local",
"Aragonese:an:local",
"Arabic:ar:local",
"Assamese:as:local",
"Azerbaijani:az:local",
"Bashkir:ba:local",
"Belarusian:be:local",
"Bulgarian:bg:local",
"Bengali:bn:local",
"Bishnupriya Manipuri:bpy:local",
"Bosnian:bs:local",
"Catalan:ca:local",
"Cherokee:chr-US-QAAA-X-WEST:local",
"Chinese (Mandarin, latin as English):cmn:local",
"Chinese (Mandarin, latin as Pinyin):cmn-LATN-PINYIN:local",
"Czech:cs:local",
"Chuvash:cv:local",
"Welsh:cy:local",
"Danish:da:local",
"German:de:local",
"Greek:el:local",
"English (Caribbean):en-029:local",
"English (Great Britain):en-GB:local",
"English (Scotland):en-GB-SCOTLAND:local",
"English (Lancaster):en-GB-X-GBCLAN:local",
"English (West Midlands):en-GB-X-GBCWMD:local",
"English (Received Pronunciation):en-GB-X-RP:local",
"English (America):en-US:local",
"English (America, New York City):en-US-NYC:local",
"Esperanto:eo:local",
"Spanish (Spain):es:local",
"Spanish (Latin America):es-419:local",
"Estonian:et:local",
"Basque:eu:local",
"Persian:fa:local",
"Persian (Pinglish):fa-LATN:local",
"Finnish:fi:local",
"French (Belgium):fr-BE:local",
"French (Switzerland):fr-CH:local",
"French (France):fr-FR:local",
"Gaelic (Irish):ga:local",
"Gaelic (Scottish):gd:local",
"Guarani:gn:local",
"Greek (Ancient):grc:local",
"Gujarati:gu:local",
"Hakka Chinese:hak:local",
"Hawaiian:haw:local",
"Hebrew:he:local",
"Hindi:hi:local",
"Croatian:hr:local",
"Haitian Creole:ht:local",
"Hungarian:hu:local",
"Armenian (East Armenia):hy:local",
"Armenian (West Armenia):hyw:local",
"Interlingua:ia:local",
"Indonesian:id:local",
"Ido:io:local",
"Icelandic:is:local",
"Italian:it:local",
"Japanese:ja:local",
"Lojban:jbo:local",
"Georgian:ka:local",
"Kazakh:kk:local",
"Greenlandic:kl:local",
"Kannada:kn:local",
"Korean:ko:local",
"Konkani:kok:local",
"Kurdish:ku:local",
"Kyrgyz:ky:local",
"Latin:la:local",
"Luxembourgish:lb:local",
"Lingua Franca Nova:lfn:local",
"Lithuanian:lt:local",
"Latgalian:ltg:local",
"Latvian:lv:local",
"Māori:mi:local",
"Macedonian:mk:local",
"Malayalam:ml:local",
"Marathi:mr:local",
"Malay:ms:local",
"Maltese:mt:local",
"Myanmar (Burmese):my:local",
"Norwegian Bokmål:nb:local",
"Nahuatl (Classical):nci:local",
"Nepali:ne:local",
"Dutch:nl:local",
"Nogai:nog:local",
"Oromo:om:local",
"Oriya:or:local",
"Punjabi:pa:local",
"Papiamento:pap:local",
"Klingon:piqd:local",
"Polish:pl:local",
"Portuguese (Portugal):pt:local",
"Portuguese (Brazil):pt-BR:local",
"Pyash:py:local",
"Lang_Belta:qdb:local",
"Quechua:qu:local",
"K'iche':quc:local",
"Quenya:qya:local",
"Romanian:ro:local",
"Russian:ru:local",
"Russian (Latvia):ru-LV:local",
"Sindhi:sd:local",
"Shan (Tai Yai):shn:local",
"Sinhala:si:local",
"Sindarin:sjn:local",
"Slovak:sk:local",
"Slovenian:sl:local",
"Lule Saami:smj:local",
"Albanian:sq:local",
"Serbian:sr:local",
"Swedish:sv:local",
"Swahili:sw:local",
"Tamil:ta:local",
"Telugu:te:local",
"Thai:th:local",
"Turkmen:tk:local",
"Setswana:tn:local",
"Turkish:tr:local",
"Tatar:tt:local",
"Uyghur:ug:local",
"Ukrainian:uk:local",
"Urdu:ur:local",
"Uzbek:uz:local",
"Vietnamese (Northern):vi:local",
"Vietnamese (Central):vi-VN-X-CENTRAL:local",
"Vietnamese (Southern):vi-VN-X-SOUTH:local",
"Chinese (Cantonese):yue:local",
"Chinese (Cantonese, latin as Jyutping):yue:local"
]
}
+143
View File
@@ -0,0 +1,143 @@
"""
Tests for the BrowserForge fingerprint-correction helpers in
camoufox.fingerprints, ported to parity with the camoufox-js launcher.
Run with:
cd pythonlib && python -m pytest tests/test_fingerprint_fixes.py -v
These guard the headless / impossible-geometry tells that BrowserForge
occasionally ships and that the camoufox-js wrapper already corrected but the
pythonlib did not.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from camoufox.fingerprints import ( # noqa: E402
clamp_window_dimensions,
fix_navigator_arch,
fix_screen_no_taskbar,
set_media_devices_defaults,
)
class TestFixNavigatorArch:
def test_corrects_armv81_to_ua_arch(self):
c = {
"navigator.userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) ...",
"navigator.platform": "Linux armv81",
"navigator.oscpu": "Linux armv81",
}
fix_navigator_arch(c, "lin")
assert c["navigator.platform"] == "Linux x86_64"
assert c["navigator.oscpu"] == "Linux x86_64"
def test_noop_when_already_consistent(self):
c = {
"navigator.userAgent": "... Linux x86_64 ...",
"navigator.platform": "Linux x86_64",
"navigator.oscpu": "Linux x86_64",
}
fix_navigator_arch(c, "lin")
assert c["navigator.platform"] == "Linux x86_64"
def test_only_runs_on_linux(self):
c = {"navigator.userAgent": "... Macintosh ...", "navigator.platform": "MacIntel"}
fix_navigator_arch(c, "mac")
assert c["navigator.platform"] == "MacIntel"
def test_noop_without_ua(self):
c = {"navigator.platform": "Linux armv81"}
fix_navigator_arch(c, "lin")
assert c["navigator.platform"] == "Linux armv81"
class TestFixScreenNoTaskbar:
def test_subtracts_taskbar_when_avail_equals_screen(self):
c = {
"screen.width": 1920,
"screen.height": 1080,
"screen.availWidth": 1920,
"screen.availHeight": 1080,
"window.outerHeight": 1080,
"window.innerHeight": 1040,
}
fix_screen_no_taskbar(c, "lin")
assert c["screen.availHeight"] == 1080 - 27 # linux panel
assert c["window.outerHeight"] == 1053
# chrome delta (1080-1040=40) preserved
assert c["window.innerHeight"] == 1053 - 40
def test_per_os_taskbar_height(self):
for os_name, px in (("win", 40), ("mac", 25), ("lin", 27)):
c = {
"screen.width": 1920,
"screen.height": 1080,
"screen.availWidth": 1920,
"screen.availHeight": 1080,
}
fix_screen_no_taskbar(c, os_name)
assert c["screen.availHeight"] == 1080 - px
def test_noop_when_avail_already_less_than_screen(self):
c = {
"screen.width": 1920,
"screen.height": 1080,
"screen.availWidth": 1920,
"screen.availHeight": 1040,
}
fix_screen_no_taskbar(c, "lin")
assert c["screen.availHeight"] == 1040
class TestClampWindowDimensions:
def test_clamps_impossible_geometry_both_axes(self):
c = {
"screen.width": 1920,
"screen.height": 1080,
"screen.availWidth": 2000, # > screen
"window.outerWidth": 2200, # > avail
"window.innerWidth": 2100, # > outer
}
clamp_window_dimensions(c)
assert c["screen.availWidth"] == 1920
assert c["window.outerWidth"] == 1920
assert c["window.innerWidth"] <= c["window.outerWidth"]
def test_preserves_chrome_delta(self):
c = {
"screen.width": 1000,
"window.outerWidth": 1200, # 200 over screen
"window.innerWidth": 1180, # 20px chrome
}
clamp_window_dimensions(c)
assert c["window.outerWidth"] == 1000
assert c["window.innerWidth"] == 1000 - 20
def test_noop_when_hierarchy_already_valid(self):
c = {
"screen.width": 1920,
"screen.availWidth": 1920,
"window.outerWidth": 1280,
"window.innerWidth": 1264,
}
clamp_window_dimensions(c)
assert c["window.outerWidth"] == 1280
assert c["window.innerWidth"] == 1264
class TestSetMediaDevicesDefaults:
def test_sets_one_mic_one_cam(self):
c = {}
set_media_devices_defaults(c)
assert c["mediaDevices:enabled"] is True
assert c["mediaDevices:micros"] == 1
assert c["mediaDevices:webcams"] == 1
assert c["mediaDevices:speakers"] == 0
def test_respects_user_set_media_devices(self):
c = {"mediaDevices:webcams": 5}
set_media_devices_defaults(c)
assert c == {"mediaDevices:webcams": 5}
+80
View File
@@ -0,0 +1,80 @@
"""Driver-side guard for daijro/camoufox#666.
Playwright's implicit 1280x720 viewport makes Juggler ask a spoofed window to
resize to a size it can never reach, deadlocking new_page(). The driver defaults
to no_viewport whenever the config spoofs any window dimension, which fixes the
hang on *already-released* browser builds -- no rebuild needed.
"""
import pytest
from camoufox.utils import attach_no_viewport_default, spoofs_window_dimensions
def _opts(config_blob: str):
"""Launch options with the config chunked the way launch_options() does."""
chunks = [config_blob[i : i + 10] for i in range(0, len(config_blob), 10)] or [""]
return {"env": {f"CAMOU_CONFIG_{i + 1}": c for i, c in enumerate(chunks)}}
@pytest.mark.parametrize(
"config, expected",
[
('{"window.outerWidth": 360}', True),
('{"window.innerHeight": 740}', True),
('{"document.body.clientWidth": 360}', True),
('{"screen.width": 360}', False),
('{"navigator.userAgent": "x"}', False),
("{}", False),
],
)
def test_detects_window_dimension_spoofing(config, expected):
assert spoofs_window_dimensions(_opts(config)) is expected
def test_reassembles_chunks_in_index_order():
"""CAMOU_CONFIG_10 must not sort before CAMOU_CONFIG_2 -- a lexicographic
join would corrupt the key we search for."""
blob = '{"padding": "' + "x" * 200 + '", "window.outerWidth": 360}'
assert spoofs_window_dimensions(_opts(blob)) is True
def test_no_env_is_not_spoofed():
assert spoofs_window_dimensions({}) is False
class _FakeBrowser:
def __init__(self):
self.calls = []
def new_page(self, **kwargs):
self.calls.append(kwargs)
return "page"
def new_context(self, **kwargs):
self.calls.append(kwargs)
return "context"
def test_defaults_to_no_viewport():
b = attach_no_viewport_default(_FakeBrowser())
b.new_page()
b.new_context()
assert b.calls == [{"no_viewport": True}, {"no_viewport": True}]
@pytest.mark.parametrize(
"override",
[{"viewport": {"width": 800, "height": 600}}, {"no_viewport": False}],
)
def test_explicit_caller_choice_always_wins(override):
"""We must never override an explicit viewport decision."""
b = attach_no_viewport_default(_FakeBrowser())
b.new_page(**override)
assert b.calls == [override]
def test_other_kwargs_are_forwarded():
b = attach_no_viewport_default(_FakeBrowser())
b.new_page(locale="en-US")
assert b.calls == [{"locale": "en-US", "no_viewport": True}]
+103
View File
@@ -0,0 +1,103 @@
"""
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