From 0e4151f82061e571b416bd20bae23ec493b129af Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Thu, 16 Jul 2026 14:38:39 -0600 Subject: [PATCH] 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 --- patches/browser-init.patch | 16 +- pythonlib/camoufox/async_api.py | 15 +- pythonlib/camoufox/fingerprints.py | 304 +++++++++++++++++++--- pythonlib/camoufox/sync_api.py | 15 +- pythonlib/camoufox/utils.py | 81 +++++- pythonlib/camoufox/voices.json | 134 +++++++++- pythonlib/tests/test_fingerprint_fixes.py | 143 ++++++++++ pythonlib/tests/test_viewport_default.py | 80 ++++++ pythonlib/tests/test_voices.py | 103 ++++++++ settings/camoufox.cfg | 48 +++- 10 files changed, 896 insertions(+), 43 deletions(-) create mode 100644 pythonlib/tests/test_fingerprint_fixes.py create mode 100644 pythonlib/tests/test_viewport_default.py create mode 100644 pythonlib/tests/test_voices.py diff --git a/patches/browser-init.patch b/patches/browser-init.patch index 5d905ac..bc2c54e 100644 --- a/patches/browser-init.patch +++ b/patches/browser-init.patch @@ -83,23 +83,27 @@ index 1cfa4b8497..bdf8d7202e 100644 // Wait until chrome is painted before executing code not critical to making the window visible this._boundDelayedStartup = this._delayedStartup.bind(this); window.addEventListener("MozAfterPaint", this._boundDelayedStartup); -@@ -366,9 +417,152 @@ var gBrowserInit = { +@@ -366,9 +417,156 @@ var gBrowserInit = { )?.removeAttribute("key"); } + // Set default size + window.resizeTo(1280, 1040); + -+ // Hijack the outer window size ++ // Hijack the outer window size. ++ // ++ // window.outerWidth/outerHeight are already spoofed unconditionally in C++ ++ // (nsGlobalWindowInner::GetOuterWidth/GetOuterHeight consult MaskConfig, see ++ // fingerprint-injection.patch), so the chrome window only has to be *resized* ++ // here -- it must not be pinned. Pinning documentElement to the spoofed size ++ // caps .browserStack, which caps the content viewport, which makes Juggler's ++ // awaitViewportDimensions handshake unsatisfiable for any larger viewport. ++ // That await has no timeout, so it hangs forever (daijro/camoufox#666). + let outerWidth, outerHeight; + if ((outerWidth = ChromeUtils.camouGetInt("window.outerWidth"))) { -+ document.documentElement.style.setProperty('width', outerWidth + 'px'); -+ browser.style.setProperty('width', outerWidth + 'px'); + window.resizeTo(outerWidth, window.outerHeight); + } + if ((outerHeight = ChromeUtils.camouGetInt("window.outerHeight"))) { -+ document.documentElement.style.setProperty('height', outerHeight + 'px'); -+ browser.style.setProperty('height', outerHeight + 'px'); + window.resizeTo(window.outerWidth, outerHeight); + } + browser.style.setProperty('box-sizing', 'content-box'); diff --git a/pythonlib/camoufox/async_api.py b/pythonlib/camoufox/async_api.py index 7d9b70d..efd7f73 100644 --- a/pythonlib/camoufox/async_api.py +++ b/pythonlib/camoufox/async_api.py @@ -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) diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index 9a138ad..8cddf50 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -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:" +# Windows SAPI -> "urn:moz-tts:sapi:" +# Linux speech-dispatcher -> "urn:moz-tts:speechd:?" +_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)});' ) diff --git a/pythonlib/camoufox/sync_api.py b/pythonlib/camoufox/sync_api.py index 5763876..fde3a88 100644 --- a/pythonlib/camoufox/sync_api.py +++ b/pythonlib/camoufox/sync_api.py @@ -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) diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index 1819322..7282856 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -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_ 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 diff --git a/pythonlib/camoufox/voices.json b/pythonlib/camoufox/voices.json index 5de819c..beab022 100644 --- a/pythonlib/camoufox/voices.json +++ b/pythonlib/camoufox/voices.json @@ -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" + ] } diff --git a/pythonlib/tests/test_fingerprint_fixes.py b/pythonlib/tests/test_fingerprint_fixes.py new file mode 100644 index 0000000..9b0a75d --- /dev/null +++ b/pythonlib/tests/test_fingerprint_fixes.py @@ -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} diff --git a/pythonlib/tests/test_viewport_default.py b/pythonlib/tests/test_viewport_default.py new file mode 100644 index 0000000..70e9eb5 --- /dev/null +++ b/pythonlib/tests/test_viewport_default.py @@ -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}] diff --git a/pythonlib/tests/test_voices.py b/pythonlib/tests/test_voices.py new file mode 100644 index 0000000..a469c0f --- /dev/null +++ b/pythonlib/tests/test_voices.py @@ -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:? + """ + + 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 diff --git a/settings/camoufox.cfg b/settings/camoufox.cfg index 5c1ee3c..8cbcd59 100644 --- a/settings/camoufox.cfg +++ b/settings/camoufox.cfg @@ -17,8 +17,46 @@ defaultPref("browser.sessionstore.restore_tabs_lazily", false); // Allow pasting in console defaultPref("devtools.selfxss.count", 100); -// WebRTC intercept IP -defaultPref("media.peerconnection.ice.no_host", true); +// WebRTC IP-leak hardening (daijro/camoufox#538). +// Camoufox already replaces the candidate IPs in the SDP layer +// (see webrtc-ip-spoofing.patch / WebRTCIPManager), but a regression +// somewhere between FF142 and FF146 means the real WAN/LAN IP can still +// reach the JS layer when the user is behind a proxy unless ICE itself +// is constrained. The pref defaults below force the proxy_only path +// for any proxied profile and prevent local host candidates from being +// gathered or surfaced. +// +// * no_host = FALSE: KEEP the host candidate. A stock Firefox always emits a +// host candidate (an mDNS .local name + a srflx). Suppressing it +// (no_host=true, the old setting) left a single-srflx shape that does NOT +// match a real browser (foundation 1, no .local host) — a fingerprint tell. +// obfuscate_host_addresses (below) replaces the real LAN IP with the +// .local mDNS name, so keeping host candidates leaks nothing while +// restoring the stock two-candidate shape. +// * default_address_only: only emit the default route address, no +// enumeration of every interface. +// * proxy_only_if_behind_proxy: when a SOCKS/HTTP proxy is configured, +// refuse to gather candidates that would bypass it. THIS is the pref that +// actually prevents the real-IP leak: it stops Firefox from sending the +// UDP STUN request around a TCP proxy (which would reveal the real IP to +// the STUN server). Behind a proxy no srflx forms, so the binary fabricates +// a synthetic srflx carrying the proxy IP instead (webrtc-ip-spoofing.patch, +// MaybeFabricateSrflxCandidate) — no real UDP ever leaves the box. +// * proxy_only_if_pbmode: same, for private-browsing windows. +// * obfuscate_host_addresses: replace the host candidate's LAN IP with an +// mDNS .local name (keeps host candidates leak-free). +// * relay_only stays opt-in — turning it on by default would break +// legitimate WebRTC; the above prefs are sufficient to plug the +// IP leak without forcing all traffic through a TURN server. +// +// Also force DNS through the SOCKS proxy so a stealth WebRTC connection +// can't reveal the real IP via a DNS side-channel. +defaultPref("media.peerconnection.ice.no_host", false); +defaultPref("media.peerconnection.ice.default_address_only", true); +defaultPref("media.peerconnection.ice.proxy_only_if_behind_proxy", true); +defaultPref("media.peerconnection.ice.proxy_only_if_pbmode", true); +defaultPref("media.peerconnection.ice.obfuscate_host_addresses", true); +defaultPref("network.proxy.socks_remote_dns", true); // Use bundled fonts defaultPref("gfx.bundled-fonts.activate", 1); @@ -762,3 +800,9 @@ defaultPref("browser.translations.enable", false); // Disable spell check defaultPref("layout.spellcheckDefault", 0); + +// Camoufox: Disable HTTPS-First so plain http:// requests aren't rewritten +// to https:// before the launch-arg proxy filter sees them. With https_first +// enabled (Firefox 146 default), simple HTTP proxies (CONNECT-only) like the +// Playwright test fixture break. Stock Playwright Firefox builds disable this. +defaultPref("dom.security.https_first", false);