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);