mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-07 08:00:33 +00:00
fix(pythonlib): per-identity salt for seeded draws; core pinning under concurrency
Found in review of the previous commits: - identity_seed() hashed only the UA, platform, screen size and core count. Those take a handful of values per OS, so over 500 launches the seed took 12-30 distinct values and every install drew its fonts, voices, GPU, media devices and canvas/audio noise seeds from that same short list. The seed now mixes in identity_salt(): derived from what the caller pinned the identity with (a Fingerprint, a preset dict, a config naming the UA) so relaunching that identity reproduces every draw, and random otherwise. A pinned preset now reproduces its noise seeds too; seeds the caller sets are kept. - Concurrent AsyncNewBrowser launches on one driver interleaved pin/restore: one browser inherited the other's mask and the driver could stay pinned. pin -> launch -> restore is serialized per driver. - Every pinned browser landed on cores 0..N-1; pins now take N adjacent cores from a random start. - A pinnable host with 1-3 cores reported 1, 2 or 3 (2 is the resistFingerprinting value); the table floor of 4 applies as on other hosts. - launch_options() callers that launch the browser themselves (launch_server, direct use) kept the drawn core count although nothing pins the browser; only Camoufox/AsyncCamoufox pass pin_cpu_cores=True now, everyone else reports the host's snapped count. - PLAUSIBLE_CORE_COUNTS gains 18, 22, 28 and 32, all recorded in the -v150 corpus. - The Windows voice list was drawn before the locale was resolved, so an fr-FR identity got en-US voices; it is drawn after locale/geoip now. - macOS "Alex" gets its com.apple.speech.synthesis.voice identifier. - CAMOU_PREFS env chunks are ASCII-only JSON (Windows getenv goes through the ANSI code page). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f0f850bf3
commit
072ad02797
@@ -105,6 +105,7 @@ async def AsyncNewBrowser(
|
||||
virtual_display = None
|
||||
|
||||
if not from_options:
|
||||
kwargs.setdefault('pin_cpu_cores', True)
|
||||
from_options = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
partial(launch_options, headless=headless, debug=debug, **kwargs),
|
||||
@@ -122,25 +123,51 @@ async def AsyncNewBrowser(
|
||||
|
||||
pin_to = pinned_core_count(from_options)
|
||||
pid = driver_pid(playwright) if pin_to else None
|
||||
previous = cpu_affinity.pin(pid, pin_to) if pid else None
|
||||
try:
|
||||
# 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)
|
||||
finally:
|
||||
if pid:
|
||||
if not pid:
|
||||
return await _launch(playwright, from_options, persistent_context, no_viewport_default, virtual_display)
|
||||
# The browser inherits the driver's mask at spawn, so two concurrent launches
|
||||
# on one driver must not interleave pin/restore: the second pin would land on
|
||||
# the first browser, and the first restore would leave the driver pinned.
|
||||
async with _pin_lock(pid):
|
||||
previous = cpu_affinity.pin(pid, pin_to)
|
||||
try:
|
||||
return await _launch(playwright, from_options, persistent_context, no_viewport_default, virtual_display)
|
||||
finally:
|
||||
cpu_affinity.restore(pid, previous)
|
||||
|
||||
|
||||
_PIN_LOCKS: Dict[int, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _pin_lock(pid: int) -> asyncio.Lock:
|
||||
# One lock per driver: a driver belongs to one event loop.
|
||||
lock = _PIN_LOCKS.get(pid)
|
||||
if lock is None:
|
||||
lock = _PIN_LOCKS[pid] = asyncio.Lock()
|
||||
return lock
|
||||
|
||||
|
||||
async def _launch(
|
||||
playwright: Playwright,
|
||||
from_options: Dict[str, Any],
|
||||
persistent_context: bool,
|
||||
no_viewport_default: bool,
|
||||
virtual_display: Optional[VirtualDisplay],
|
||||
) -> Union[Browser, BrowserContext]:
|
||||
# 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)
|
||||
|
||||
|
||||
def _proxy_url_with_creds(proxy: Dict[str, str]) -> str:
|
||||
"""Builds a proxy URL string with embedded credentials."""
|
||||
parsed = urlparse(proxy.get("server", ""))
|
||||
|
||||
@@ -16,6 +16,7 @@ the host's (snapped) count.
|
||||
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
from typing import Iterable, List, Optional, Sequence
|
||||
|
||||
|
||||
@@ -42,9 +43,22 @@ def host_cores() -> Optional[List[int]]:
|
||||
return list(range(n)) if n else None
|
||||
|
||||
|
||||
def _pick(cores: Sequence[int], count: int) -> List[int]:
|
||||
"""`count` adjacent cores from a random starting point (wrapping). Always
|
||||
taking the first `count` stacked every browser on one host onto cores
|
||||
0..count-1, so concurrent browsers measured far less parallelism than they
|
||||
report; adjacent cores keep the SMT topology a real machine of that size
|
||||
would have."""
|
||||
start = random.randrange(len(cores))
|
||||
return sorted((list(cores[start:]) + list(cores[:start]))[:count])
|
||||
|
||||
|
||||
def pin(pid: int, count: int) -> Optional[Sequence[int]]:
|
||||
"""Restrict `pid` to its first `count` cores. Returns the previous set so
|
||||
it can be handed back to `restore()`, or None if nothing was changed."""
|
||||
"""Restrict `pid` to `count` of its cores. Returns the previous set so it
|
||||
can be handed back to `restore()`, or None if nothing was changed.
|
||||
|
||||
The caller must not pin the same process for two launches at once: the
|
||||
browser inherits whatever mask the driver has when it is spawned."""
|
||||
if count < 1 or not supported():
|
||||
return None
|
||||
system = platform.system()
|
||||
@@ -53,7 +67,7 @@ def pin(pid: int, count: int) -> Optional[Sequence[int]]:
|
||||
before = sorted(os.sched_getaffinity(pid)) # type: ignore[attr-defined]
|
||||
if count >= len(before):
|
||||
return None
|
||||
os.sched_setaffinity(pid, set(before[:count])) # type: ignore[attr-defined]
|
||||
os.sched_setaffinity(pid, set(_pick(before, count))) # type: ignore[attr-defined]
|
||||
return before
|
||||
except OSError:
|
||||
return None
|
||||
@@ -64,7 +78,7 @@ def pin(pid: int, count: int) -> Optional[Sequence[int]]:
|
||||
before = _mask_to_cores(before_mask)
|
||||
if count >= len(before):
|
||||
return None
|
||||
return before if _win_set_mask(pid, _cores_to_mask(before[:count])) else None
|
||||
return before if _win_set_mask(pid, _cores_to_mask(_pick(before, count))) else None
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import unicodedata
|
||||
from dataclasses import asdict, dataclass
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from pathlib import Path
|
||||
from random import Random, choice, randint, randrange, random, sample, shuffle
|
||||
from typing import Any, Dict, FrozenSet, List, Optional, Tuple
|
||||
@@ -333,16 +335,40 @@ WINDOWS_11_MARKER_FONTS = frozenset(_BASE_VARIANT_FONTS_WINDOWS[1])
|
||||
|
||||
|
||||
|
||||
def identity_seed(config: Dict[str, Any]) -> int:
|
||||
"""A stable seed for the per-identity draws (fonts, voices).
|
||||
def identity_salt(pinned: Any = None) -> int:
|
||||
"""The entropy that makes identity_seed() belong to ONE identity.
|
||||
|
||||
Two launches that present the same identity (same UA, platform, screen,
|
||||
cores, GPU) must present the same font and voice lists: a page that keeps
|
||||
cookies across launches and sees the font set or the voice list change
|
||||
under an otherwise identical device reads it as a spoofed browser
|
||||
(daijro/camoufox#442, #765, #378). Deriving the seed from the identity
|
||||
itself makes the draw a pure function of the fingerprint, so `from_options`
|
||||
replays and persistent contexts are stable without any new state.
|
||||
The presented values identity_seed() hashes are shared by many unrelated
|
||||
launches: browserforge gives each OS only a handful of screens and UAs, so
|
||||
over 500 launches per OS the unsalted seed took 12-30 distinct values, and
|
||||
every Camoufox install everywhere drew its fonts, voices, GPU, media devices
|
||||
and canvas/audio noise seeds from that same short list.
|
||||
|
||||
Pass whatever the caller pinned the identity with -- a browserforge
|
||||
Fingerprint, a preset dict, the caller's own config -- to get a salt that
|
||||
is stable across launches of that identity (daijro/camoufox#442, #765);
|
||||
pass nothing for a fresh identity, which gets a random salt.
|
||||
"""
|
||||
if pinned is None:
|
||||
return secrets.randbits(64)
|
||||
if is_dataclass(pinned) and not isinstance(pinned, type):
|
||||
pinned = asdict(pinned)
|
||||
import orjson
|
||||
|
||||
blob = orjson.dumps(pinned, option=orjson.OPT_SORT_KEYS | orjson.OPT_NON_STR_KEYS, default=str)
|
||||
return int.from_bytes(hashlib.sha256(blob).digest()[:8], 'big')
|
||||
|
||||
|
||||
def identity_seed(config: Dict[str, Any], salt: int = 0) -> int:
|
||||
"""A seed for the per-identity draws (fonts, voices, GPU, media devices,
|
||||
noise seeds): a pure function of the presented identity and its salt.
|
||||
|
||||
Two launches that present the same identity must present the same font and
|
||||
voice lists: a page that keeps cookies across launches and sees the font
|
||||
set or the voice list change under an otherwise identical device reads it
|
||||
as a spoofed browser (daijro/camoufox#442, #765, #378). The presented
|
||||
values alone are far too common to tell identities apart, so callers mix in
|
||||
identity_salt() (see there).
|
||||
"""
|
||||
import zlib
|
||||
parts = [
|
||||
@@ -352,6 +378,7 @@ def identity_seed(config: Dict[str, Any]) -> int:
|
||||
str(config.get('screen.height', '')),
|
||||
str(config.get('navigator.hardwareConcurrency', '')),
|
||||
# not the GPU: it is sampled after the font draw in launch_options
|
||||
str(salt),
|
||||
]
|
||||
return zlib.crc32('|'.join(parts).encode('utf-8')) & 0xFFFFFFFF
|
||||
|
||||
@@ -587,7 +614,9 @@ def _voice_uri(os_key: str, name: str, lang: str) -> str:
|
||||
_MAC_NOVELTY_VOICES = frozenset(
|
||||
{'Albert', 'Bad News', 'Bahh', 'Bells', 'Boing', 'Bubbles', 'Cellos', 'Wobble', 'Good News', 'Jester',
|
||||
'Organ', 'Superstar', 'Trinoids', 'Whisper', 'Zarvox', 'Fred', 'Junior', 'Kathy', 'Ralph',
|
||||
'Bruce', 'Vicki', 'Victoria', 'Agnes', 'Princess', 'Hysterical', 'Pipe Organ', 'Deranged'}
|
||||
'Bruce', 'Vicki', 'Victoria', 'Agnes', 'Princess', 'Hysterical', 'Pipe Organ', 'Deranged',
|
||||
# not a novelty voice, but the same MacinTalk identifier family
|
||||
'Alex'}
|
||||
)
|
||||
_MAC_ELOQUENCE_VOICES = frozenset({'Eddy', 'Flo', 'Grandma', 'Grandpa', 'Reed', 'Rocko', 'Sandy', 'Shelley'})
|
||||
_VOICE_URIS_CACHE: Optional[Dict[str, Dict[str, str]]] = None
|
||||
@@ -760,30 +789,6 @@ def _generate_random_voice_subset(
|
||||
# (SAPI), macOS or Linux (measured 2026-09-14 on all three), so a spoofed
|
||||
# default would be the odd one out.
|
||||
return voices
|
||||
if os_key == 'mac':
|
||||
pref = next((i for i, v in enumerate(voices) if v['name'] in ('Samantha', 'Alex') and (not locale or v['lang'].lower() == locale.lower())), -1)
|
||||
if pref >= 0:
|
||||
voices[pref]['isDefault'] = True
|
||||
return voices
|
||||
# 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(
|
||||
@@ -837,11 +842,10 @@ def host_cpu_count() -> Optional[int]:
|
||||
|
||||
# Core counts real desktop machines ship with, taken from the RECORDED
|
||||
# fingerprint corpus rather than invented: fingerprint-presets.json and
|
||||
# -v150.json between them contain 2, 4, 6, 8, 10, 12, 14, 16, 20 and 24.
|
||||
#
|
||||
# 24 was missing from this table and is restored (2026-09-15): it is a real
|
||||
# recorded value on Windows (2/75) and Linux (2/18), and excluding it snapped
|
||||
# genuine 24-core machines down to 20 for no reason.
|
||||
# -v150.json between them contain 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24,
|
||||
# 28 and 32 (18: macOS 2/67; 22: Windows 6/180, Linux 2/65; 28: Windows 2/180,
|
||||
# Linux 1/65; 32: Linux 1/65 -- all in -v150). Leaving any of them out snapped
|
||||
# genuine machines with that count down to the next entry for no reason.
|
||||
#
|
||||
# 2 is recorded too -- and is common, 6/30 macOS presets (20%) -- but is
|
||||
# deliberately EXCLUDED (user, 2026-09-15): 2 is what Firefox reports under
|
||||
@@ -849,13 +853,13 @@ def host_cpu_count() -> Optional[int]:
|
||||
# RFP is not. So a draw of 2 snaps up to the table floor of 4.
|
||||
#
|
||||
# A host outside this table would hand its own oddity to the fingerprint: a
|
||||
# 64-thread build box reports 24, anything under 4 threads reports 4. Odd
|
||||
# 64-thread build box reports 32, anything under 4 threads reports 4. Odd
|
||||
# counts (5, 7, 9, 11, 13, 15) never appear in the corpus -- they are
|
||||
# browserforge Bayesian synthesis -- so they keep getting snapped down.
|
||||
PLAUSIBLE_CORE_COUNTS = (4, 6, 8, 10, 12, 14, 16, 20, 24)
|
||||
PLAUSIBLE_CORE_COUNTS = (4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28, 32)
|
||||
|
||||
|
||||
def fix_hardware_concurrency(config: Dict[str, Any]) -> None:
|
||||
def fix_hardware_concurrency(config: Dict[str, Any], can_pin: Optional[bool] = None) -> None:
|
||||
"""navigator.hardwareConcurrency = the host's parallelism, snapped DOWN
|
||||
into PLAUSIBLE_CORE_COUNTS.
|
||||
|
||||
@@ -889,12 +893,17 @@ def fix_hardware_concurrency(config: Dict[str, Any]) -> None:
|
||||
# pin (macOS), falls back to the snapped host count.
|
||||
from .cpu_affinity import supported as _can_pin
|
||||
|
||||
# can_pin=False: the caller launches the browser itself and nothing will
|
||||
# pin it (launch_server, launch_options used directly), so a kept draw
|
||||
# would be measured as the host count. None: whatever the host supports.
|
||||
pinnable = _can_pin() and can_pin is not False
|
||||
|
||||
cap = int(n)
|
||||
host_allowed = [c for c in PLAUSIBLE_CORE_COUNTS if c <= cap]
|
||||
host_value = host_allowed[-1] if host_allowed else PLAUSIBLE_CORE_COUNTS[0]
|
||||
|
||||
drawn = config.get('navigator.hardwareConcurrency')
|
||||
if _can_pin() and isinstance(drawn, int) and drawn >= 1:
|
||||
if pinnable and isinstance(drawn, int) and drawn >= 1:
|
||||
# The fingerprint's value is kept for diversity, but it still has to be
|
||||
# a count a real desktop ships with. Accepting any 1..host let
|
||||
# browserforge's low/odd draws through: over 400 linux draws, 8.0% were
|
||||
@@ -906,8 +915,10 @@ def fix_hardware_concurrency(config: Dict[str, Any]) -> None:
|
||||
# into the table instead, capped by the host so pinning can honour it.
|
||||
target = min(drawn, cap)
|
||||
allowed = [c for c in PLAUSIBLE_CORE_COUNTS if c <= target]
|
||||
# The floor is the table's even on a 1-3 core host: min(4, cap)
|
||||
# reported 1, 2 or 3 there, and 2 is the resistFingerprinting value.
|
||||
config['navigator.hardwareConcurrency'] = (
|
||||
allowed[-1] if allowed else min(PLAUSIBLE_CORE_COUNTS[0], cap)
|
||||
allowed[-1] if allowed else PLAUSIBLE_CORE_COUNTS[0]
|
||||
)
|
||||
return
|
||||
config['navigator.hardwareConcurrency'] = host_value
|
||||
@@ -1049,7 +1060,7 @@ def clamp_window_position(config: Dict[str, Any]) -> None:
|
||||
config[pos_key] = max(0, min(pos, screen - outer))
|
||||
|
||||
|
||||
def set_media_devices_defaults(config: Dict[str, Any]) -> None:
|
||||
def set_media_devices_defaults(config: Dict[str, Any], salt: int = 0) -> None:
|
||||
"""Give the identity a plausible set of media devices.
|
||||
|
||||
The patched media backend (media-device-spoofing.patch) enumerates and
|
||||
@@ -1076,7 +1087,7 @@ def set_media_devices_defaults(config: Dict[str, Any]) -> None:
|
||||
os_key = 'mac'
|
||||
else:
|
||||
os_key = 'lin'
|
||||
config.update(draw_media_devices(os_key, identity_seed(config)))
|
||||
config.update(draw_media_devices(os_key, identity_seed(config, salt)))
|
||||
|
||||
|
||||
_MEDIA_DEVICES_CACHE: Optional[Dict[str, Any]] = None
|
||||
@@ -1488,10 +1499,15 @@ def _app_version_from_user_agent(user_agent: str) -> Optional[str]:
|
||||
return f"5.0 ({'; '.join(kept)})" if kept else None
|
||||
|
||||
|
||||
def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any]:
|
||||
def from_preset(preset: Dict, ff_version: Optional[str] = None, salt: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert a real fingerprint preset to CAMOU_CONFIG format.
|
||||
|
||||
`salt` (identity_salt) keys the font/voice draws; None draws a fresh one, so
|
||||
two users of the same recorded device do not also share its font list.
|
||||
"""
|
||||
if salt is None:
|
||||
salt = identity_salt()
|
||||
config: Dict[str, Any] = {}
|
||||
|
||||
nav = preset.get('navigator', {})
|
||||
@@ -1578,7 +1594,7 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any
|
||||
else:
|
||||
target_os = 'macos'
|
||||
try:
|
||||
config['fonts'] = _generate_random_font_subset(target_os, seed=identity_seed(config))
|
||||
config['fonts'] = _generate_random_font_subset(target_os, seed=identity_seed(config, salt))
|
||||
except Exception:
|
||||
# Fallback to preset fonts if font generation fails
|
||||
if preset.get('fonts'):
|
||||
@@ -1591,7 +1607,7 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any
|
||||
config['fonts'] = fonts
|
||||
# Generate a unique random voice subset from the OS voice list
|
||||
try:
|
||||
config['voices'] = _generate_random_voice_subset(target_os, seed=identity_seed(config))
|
||||
config['voices'] = _generate_random_voice_subset(target_os, seed=identity_seed(config, salt))
|
||||
except Exception:
|
||||
if preset.get('speechVoices'):
|
||||
config['voices'] = _normalize_preset_voices(
|
||||
@@ -1727,6 +1743,9 @@ def generate_context_fingerprint(
|
||||
fp = generate_fingerprint(os=os)
|
||||
config = from_browserforge(fp, ff_version)
|
||||
|
||||
# A fresh identity: every seeded draw below gets its own salt.
|
||||
_salt = identity_salt()
|
||||
|
||||
# Add seeds (BrowserForge doesn't generate these)
|
||||
config.setdefault('fonts:spacing_seed', 0) # perturbation off; see utils.launch_options
|
||||
config.setdefault('audio:seed', randint(1, 4_294_967_295)) # nosec
|
||||
@@ -1743,14 +1762,14 @@ def generate_context_fingerprint(
|
||||
# Add fonts (BrowserForge doesn't generate these)
|
||||
if 'fonts' not in config:
|
||||
try:
|
||||
config['fonts'] = _generate_random_font_subset(os_name, seed=identity_seed(config))
|
||||
config['fonts'] = _generate_random_font_subset(os_name, seed=identity_seed(config, _salt))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add voices (BrowserForge doesn't generate these)
|
||||
if 'voices' not in config:
|
||||
try:
|
||||
config['voices'] = _generate_random_voice_subset(os_name, seed=identity_seed(config))
|
||||
config['voices'] = _generate_random_voice_subset(os_name, seed=identity_seed(config, _salt))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ def NewBrowser(
|
||||
virtual_display = None
|
||||
|
||||
if not from_options:
|
||||
kwargs.setdefault('pin_cpu_cores', True)
|
||||
from_options = launch_options(headless=headless, debug=debug, **kwargs)
|
||||
|
||||
# Playwright's default viewport deadlocks Juggler when the window is spoofed
|
||||
|
||||
+76
-39
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
@@ -22,7 +23,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, fix_navigator_arch, fix_hardware_concurrency, identity_seed, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, sample_webgl_for_screen, set_media_devices_defaults, WINDOWS_11_MARKER_FONTS
|
||||
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_hardware_concurrency, identity_salt, identity_seed, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, sample_webgl_for_screen, set_media_devices_defaults, WINDOWS_11_MARKER_FONTS
|
||||
from .geolocation import geoip_allowed, get_geolocation
|
||||
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
|
||||
from .locales import handle_locales
|
||||
@@ -173,7 +174,10 @@ def get_pref_env_vars(prefs: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""
|
||||
if not prefs:
|
||||
return {}
|
||||
data = orjson.dumps(prefs).decode('utf-8')
|
||||
# ASCII only: on Windows autoconfig's getenv() reads the environment through
|
||||
# the ANSI code page, which would mangle a raw UTF-8 pref value (\u escapes
|
||||
# survive it and JSON.parse restores them).
|
||||
data = json.dumps(prefs, ensure_ascii=True, separators=(',', ':'))
|
||||
chunk_size = 2047 if OS_NAME == 'win' else 32767
|
||||
return {
|
||||
f"CAMOU_PREFS_{(i // chunk_size) + 1}": data[i : i + chunk_size]
|
||||
@@ -715,6 +719,7 @@ def launch_options(
|
||||
i_know_what_im_doing: Optional[bool] = None,
|
||||
debug: Optional[bool] = None,
|
||||
virtual_display: Optional[str] = None,
|
||||
pin_cpu_cores: Optional[bool] = None,
|
||||
**launch_options: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -806,6 +811,11 @@ def launch_options(
|
||||
Prints the config being sent to Camoufox.
|
||||
virtual_display (Optional[str]):
|
||||
Virtual display number. Ex: ':99'. This is handled by Camoufox & AsyncCamoufox.
|
||||
pin_cpu_cores (Optional[bool]):
|
||||
The browser will be pinned to navigator.hardwareConcurrency cores
|
||||
(Linux/Windows), so the fingerprint's core count can be kept. Set by
|
||||
Camoufox & AsyncCamoufox, which apply the pin; without it the host's
|
||||
own (snapped) core count is reported, since nothing pins the browser.
|
||||
webgl_config (Optional[Tuple[str, str]]):
|
||||
Use a specific WebGL vendor/renderer pair. Passed as a tuple of (vendor, renderer).
|
||||
**launch_options (Dict[str, Any]):
|
||||
@@ -871,6 +881,20 @@ def launch_options(
|
||||
_user_set_dnt = 'navigator.doNotTrack' in config
|
||||
_user_set_gpc = 'navigator.globalPrivacyControl' in config
|
||||
_user_set_accept_encoding = 'headers.Accept-Encoding' in config
|
||||
_user_set_noise_seeds = {k for k in ('audio:seed', 'canvas:seed') if k in config}
|
||||
|
||||
# The salt that makes every seeded draw belong to this identity (see
|
||||
# fingerprints.identity_salt): stable when the caller pinned the identity
|
||||
# -- a Fingerprint, a preset dict, or their own config naming the UA --
|
||||
# and fresh otherwise.
|
||||
if fingerprint is not None:
|
||||
_identity_salt = identity_salt(fingerprint)
|
||||
elif isinstance(fingerprint_preset, dict):
|
||||
_identity_salt = identity_salt(fingerprint_preset)
|
||||
elif 'navigator.userAgent' in config:
|
||||
_identity_salt = identity_salt(dict(config))
|
||||
else:
|
||||
_identity_salt = identity_salt()
|
||||
|
||||
# Assert the target OS is valid
|
||||
if os:
|
||||
@@ -908,7 +932,7 @@ def launch_options(
|
||||
else:
|
||||
preset = get_random_preset(os=os, ff_version=ff_version_str)
|
||||
if preset:
|
||||
merge_into(config, from_preset(preset, ff_version_str))
|
||||
merge_into(config, from_preset(preset, ff_version_str, salt=_identity_salt))
|
||||
_used_preset = True
|
||||
|
||||
# Bound the geometry to the real display. BrowserForge only honours this when
|
||||
@@ -939,7 +963,7 @@ def launch_options(
|
||||
# impossible-geometry tells, unless the user is driving these themselves.
|
||||
if not _user_set_navigator:
|
||||
fix_navigator_arch(config, target_os)
|
||||
fix_hardware_concurrency(config)
|
||||
fix_hardware_concurrency(config, can_pin=bool(pin_cpu_cores))
|
||||
if not _user_set_screen_window:
|
||||
# Lift netbook-era geometry to something current hardware reports,
|
||||
# before the display clamp below so a genuinely small real monitor
|
||||
@@ -993,7 +1017,7 @@ def launch_options(
|
||||
try:
|
||||
config['fonts'] = _generate_random_font_subset(
|
||||
os_name,
|
||||
seed=identity_seed(config),
|
||||
seed=identity_seed(config, _identity_salt),
|
||||
# host's own OS on macOS/Windows: the real system fonts are used
|
||||
# (font-hijacker.patch keeps the bundle inactive), so only the
|
||||
# OS base is claimed
|
||||
@@ -1002,39 +1026,12 @@ def launch_options(
|
||||
except Exception:
|
||||
update_fonts(config, target_os)
|
||||
|
||||
# Spoof the speech-synthesis voice list.
|
||||
#
|
||||
# This has to fail CLOSED. Firefox registers the host's speech-dispatcher /
|
||||
# SAPI / NSSpeech voices unless something stops it, and nsSynthVoiceRegistry
|
||||
# only stops it when Camoufox owns the list. Leaving `voices` unset -- which
|
||||
# the old `except Exception: pass` did on any generation failure -- exposed
|
||||
# every native voice on the box (14805 espeak-ng entries on a stock Linux
|
||||
# install) under a fingerprint claiming macOS or Windows: it both leaks the
|
||||
# real host OS and contradicts the rest of the profile (#731).
|
||||
if not _user_set_voices or 'voices' not in config:
|
||||
os_name_v = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'}.get(target_os, 'macos')
|
||||
try:
|
||||
config['voices'] = _generate_random_voice_subset(
|
||||
os_name_v, config.get('navigator.language'), seed=identity_seed(config)
|
||||
)
|
||||
except Exception:
|
||||
# An empty list still blocks the host's voices (see below), so a
|
||||
# generation failure degrades to "no voices" rather than "all of
|
||||
# the host's".
|
||||
config['voices'] = []
|
||||
|
||||
# Pin the block explicitly instead of relying on a non-empty list to imply
|
||||
# it, so an empty list -- or one whose entries the browser rejects as
|
||||
# malformed -- cannot fall through to the host's native voices. set_into
|
||||
# leaves an explicit caller value alone.
|
||||
set_into(config, 'voices:blockIfNotDefined', True)
|
||||
|
||||
# Draw the identity's media devices (counts + OS-style labels/groups from
|
||||
# media-devices.json, seeded by the identity) unless the caller set any
|
||||
# mediaDevices: key. An empty enumerateDevices() list is a headless tell;
|
||||
# a wrong label after a grant is a spoof tell.
|
||||
if not _user_set_media_devices:
|
||||
set_media_devices_defaults(config)
|
||||
set_media_devices_defaults(config, _identity_salt)
|
||||
|
||||
# Scrollbars: a stock Firefox on a GNOME/KDE desktop and on macOS draws
|
||||
# overlay scrollbars (no layout gutter, scrollbar-width "auto"). On Windows it
|
||||
@@ -1144,9 +1141,13 @@ def launch_options(
|
||||
# audio/canvas noise seeds follow the identity: a returning "same device"
|
||||
# must reproduce its audio and canvas hashes (#442/#765). Derived, not
|
||||
# equal, so the two streams differ; never 0 (0 disables the noise).
|
||||
_ident = identity_seed(config)
|
||||
set_into(config, 'audio:seed', ((_ident * 2654435761 + 97) & 0xFFFFFFFF) or 1)
|
||||
set_into(config, 'canvas:seed', ((_ident * 40503 + 12345) & 0xFFFFFFFF) or 1)
|
||||
# A preset draws its own random seeds; they are replaced here too so a
|
||||
# pinned preset reproduces them, but a seed the caller set is kept.
|
||||
_ident = identity_seed(config, _identity_salt)
|
||||
if 'audio:seed' not in _user_set_noise_seeds:
|
||||
config['audio:seed'] = ((_ident * 2654435761 + 97) & 0xFFFFFFFF) or 1
|
||||
if 'canvas:seed' not in _user_set_noise_seeds:
|
||||
config['canvas:seed'] = ((_ident * 40503 + 12345) & 0xFFFFFFFF) or 1
|
||||
|
||||
# Set geolocation
|
||||
if geoip:
|
||||
@@ -1221,6 +1222,42 @@ def launch_options(
|
||||
requested = 'en-US'
|
||||
firefox_user_prefs.setdefault('intl.locale.requested', requested)
|
||||
|
||||
# Spoof the speech-synthesis voice list.
|
||||
#
|
||||
# This has to fail CLOSED. Firefox registers the host's speech-dispatcher /
|
||||
# SAPI / NSSpeech voices unless something stops it, and nsSynthVoiceRegistry
|
||||
# only stops it when Camoufox owns the list. Leaving `voices` unset -- which
|
||||
# the old `except Exception: pass` did on any generation failure -- exposed
|
||||
# every native voice on the box (14805 espeak-ng entries on a stock Linux
|
||||
# install) under a fingerprint claiming macOS or Windows: it both leaks the
|
||||
# real host OS and contradicts the rest of the profile (#731).
|
||||
#
|
||||
# Drawn after the locale is resolved (locale= or geoip): the Windows voice
|
||||
# list is the display language's pack, so an fr-FR identity has French
|
||||
# voices, not the en-US ones.
|
||||
if not _user_set_voices or 'voices' not in config:
|
||||
os_name_v = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'}.get(target_os, 'macos')
|
||||
voice_locale = config.get('navigator.language')
|
||||
if config.get('locale:language'):
|
||||
voice_locale = '-'.join(
|
||||
part for part in (config['locale:language'], config.get('locale:region')) if part
|
||||
)
|
||||
try:
|
||||
config['voices'] = _generate_random_voice_subset(
|
||||
os_name_v, voice_locale, seed=identity_seed(config, _identity_salt)
|
||||
)
|
||||
except Exception:
|
||||
# An empty list still blocks the host's voices (see below), so a
|
||||
# generation failure degrades to "no voices" rather than "all of
|
||||
# the host's".
|
||||
config['voices'] = []
|
||||
|
||||
# Pin the block explicitly instead of relying on a non-empty list to imply
|
||||
# it, so an empty list -- or one whose entries the browser rejects as
|
||||
# malformed -- cannot fall through to the host's native voices. set_into
|
||||
# leaves an explicit caller value alone.
|
||||
set_into(config, 'voices:blockIfNotDefined', True)
|
||||
|
||||
# Pass the humanize option
|
||||
if humanize:
|
||||
set_into(config, 'humanize', True)
|
||||
@@ -1254,10 +1291,10 @@ def launch_options(
|
||||
else:
|
||||
# If the user has provided a specific WebGL vendor/renderer pair, use it
|
||||
if webgl_config:
|
||||
webgl_fp = sample_webgl(target_os, *webgl_config, seed=identity_seed(config))
|
||||
webgl_fp = sample_webgl(target_os, *webgl_config, seed=identity_seed(config, _identity_salt))
|
||||
elif config.get('webGl:vendor') and config.get('webGl:renderer'):
|
||||
# Preset already set vendor/renderer — sample matching WebGL params
|
||||
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'], seed=identity_seed(config))
|
||||
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'], seed=identity_seed(config, _identity_salt))
|
||||
else:
|
||||
# Synthetic path: keep the GPU coherent with the screen BrowserForge
|
||||
# already picked. Sampling the two independently yields pairs no
|
||||
@@ -1265,7 +1302,7 @@ def launch_options(
|
||||
# panel -- which consistency checks read as masking (#729).
|
||||
webgl_fp = sample_webgl_for_screen(
|
||||
target_os, config.get('screen.width'), config.get('screen.height'),
|
||||
seed=identity_seed(config),
|
||||
seed=identity_seed(config, _identity_salt),
|
||||
)
|
||||
enable_webgl2 = webgl_fp.pop('webGl2Enabled')
|
||||
|
||||
|
||||
@@ -321,9 +321,9 @@ class TestFixHardwareConcurrency:
|
||||
|
||||
def test_snaps_host_parallelism_when_it_cannot_pin(self, monkeypatch):
|
||||
# The host count, snapped DOWN into the
|
||||
# counts real machines ship with; the tails report 24 / 4. Used when the
|
||||
# counts real machines ship with; the tails report 32 / 4. Used when the
|
||||
# draw exceeds the host or the host cannot pin (macOS).
|
||||
# 24 is in the table (recorded on real Windows/Linux devices); 2 is NOT,
|
||||
# 18/22/24/28/32 are in the table (recorded on real devices); 2 is NOT,
|
||||
# although it is recorded, because 2 is the resistFingerprinting value.
|
||||
from camoufox import cpu_affinity, fingerprints as fp
|
||||
|
||||
@@ -332,8 +332,10 @@ class TestFixHardwareConcurrency:
|
||||
(16, 16),
|
||||
(10, 10),
|
||||
(24, 24),
|
||||
(32, 24),
|
||||
(64, 24),
|
||||
(26, 24),
|
||||
(32, 32),
|
||||
(64, 32),
|
||||
(22, 22),
|
||||
(7, 6),
|
||||
(5, 4),
|
||||
(2, 4),
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Per-identity draws: unrelated launches must not share them, a pinned identity must.
|
||||
|
||||
identity_seed() used to hash only the UA, platform, screen size and core count.
|
||||
Those take a handful of values per OS, so over 500 launches the seed took 12-30
|
||||
distinct values, and every install drew its fonts, voices, GPU, media devices and
|
||||
canvas/audio noise seeds from that same short list.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest import mock
|
||||
|
||||
import orjson
|
||||
import pytest
|
||||
|
||||
from camoufox import cpu_affinity, utils
|
||||
from camoufox import fingerprints as fp
|
||||
|
||||
|
||||
@contextmanager
|
||||
def host():
|
||||
with mock.patch.object(utils, "get_screen_cons", lambda headless: None), (
|
||||
mock.patch.object(utils, "has_display", lambda env: False)
|
||||
), mock.patch.object(utils, "installed_verstr", lambda: "150.0.2"), (
|
||||
mock.patch.object(utils, "launch_path", lambda **kwargs: "/nonexistent/camoufox")
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def config_of(options):
|
||||
env = options["env"]
|
||||
chunks = sorted(
|
||||
(int(k.rsplit("_", 1)[1]), v) for k, v in env.items() if k.startswith("CAMOU_CONFIG_")
|
||||
)
|
||||
return orjson.loads("".join(chunk for _, chunk in chunks))
|
||||
|
||||
|
||||
def launch(**kwargs):
|
||||
kwargs.setdefault("os", "linux")
|
||||
kwargs.setdefault("headless", True)
|
||||
kwargs.setdefault("i_know_what_im_doing", True)
|
||||
with host():
|
||||
return config_of(utils.launch_options(**kwargs))
|
||||
|
||||
|
||||
DRAWN = ("canvas:seed", "audio:seed", "fonts", "voices", "webGl:renderer")
|
||||
|
||||
|
||||
def drawn(config):
|
||||
return {k: orjson.dumps(config.get(k)) for k in DRAWN}
|
||||
|
||||
|
||||
class TestUnpinnedLaunchesAreDistinct:
|
||||
def test_noise_seeds_do_not_collide(self):
|
||||
seeds = [launch()["canvas:seed"] for _ in range(40)]
|
||||
# 40 draws from 2**32: any collision means the seed space collapsed.
|
||||
assert len(set(seeds)) == len(seeds)
|
||||
|
||||
def test_same_presented_values_still_differ(self):
|
||||
# The same UA/platform/screen/cores, i.e. what two users on the same
|
||||
# common machine present, must not yield the same noise seeds.
|
||||
config = {"navigator.userAgent": "x", "navigator.platform": "Win32",
|
||||
"screen.width": 1920, "screen.height": 1080, "navigator.hardwareConcurrency": 8}
|
||||
seeds = {fp.identity_seed(config, fp.identity_salt()) for _ in range(200)}
|
||||
assert len(seeds) == 200
|
||||
|
||||
|
||||
class TestPinnedIdentityIsStable:
|
||||
def test_fixed_fingerprint_reproduces_every_draw(self):
|
||||
fingerprint = fp.generate_fingerprint(os="linux")
|
||||
first = launch(fingerprint=fingerprint)
|
||||
second = launch(fingerprint=fingerprint)
|
||||
assert drawn(first) == drawn(second)
|
||||
|
||||
def test_fixed_preset_reproduces_noise_seeds(self):
|
||||
preset = fp.get_random_preset(os="windows", ff_version="150")
|
||||
if not preset:
|
||||
pytest.skip("no presets bundled")
|
||||
first = launch(os="windows", fingerprint_preset=preset)
|
||||
second = launch(os="windows", fingerprint_preset=preset)
|
||||
assert (first["canvas:seed"], first["audio:seed"]) == (second["canvas:seed"], second["audio:seed"])
|
||||
assert first["fonts"] == second["fonts"]
|
||||
|
||||
def test_caller_seeds_are_kept(self):
|
||||
config = launch(config={"canvas:seed": 7, "audio:seed": 9})
|
||||
assert (config["canvas:seed"], config["audio:seed"]) == (7, 9)
|
||||
|
||||
def test_salt_of_equal_objects_is_equal(self):
|
||||
a = fp.generate_fingerprint(os="windows")
|
||||
assert fp.identity_salt(a) == fp.identity_salt(a)
|
||||
assert fp.identity_salt({"a": 1, "b": 2}) == fp.identity_salt({"b": 2, "a": 1})
|
||||
|
||||
|
||||
class TestVoicesFollowLocale:
|
||||
def test_windows_fr_identity_has_french_voices(self, monkeypatch):
|
||||
for _ in range(5):
|
||||
config = launch(os="windows", locale="fr-FR")
|
||||
langs = {v["lang"] for v in config["voices"]}
|
||||
assert "fr-FR" in langs, langs
|
||||
|
||||
|
||||
class TestCoreCountFloor:
|
||||
def test_small_pinnable_host_reports_table_floor(self, monkeypatch):
|
||||
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
|
||||
for host_cores in (1, 2, 3):
|
||||
monkeypatch.setattr(fp, "host_cpu_count", lambda n=host_cores: n)
|
||||
for drawn_cores in (1, 2, 3, 8):
|
||||
c = {"navigator.hardwareConcurrency": drawn_cores}
|
||||
fp.fix_hardware_concurrency(c)
|
||||
assert c["navigator.hardwareConcurrency"] == 4, (host_cores, drawn_cores)
|
||||
|
||||
def test_unpinned_launch_reports_host(self, monkeypatch):
|
||||
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
|
||||
monkeypatch.setattr(fp, "host_cpu_count", lambda: 16)
|
||||
c = {"navigator.hardwareConcurrency": 8}
|
||||
fp.fix_hardware_concurrency(c, can_pin=False)
|
||||
assert c["navigator.hardwareConcurrency"] == 16
|
||||
|
||||
def test_recorded_counts_are_in_the_table(self):
|
||||
for n in (18, 22, 28, 32):
|
||||
assert n in fp.PLAUSIBLE_CORE_COUNTS
|
||||
|
||||
|
||||
class TestAffinityPick:
|
||||
def test_adjacent_cores_from_a_random_start(self):
|
||||
cores = list(range(16))
|
||||
starts = set()
|
||||
for _ in range(200):
|
||||
picked = cpu_affinity._pick(cores, 4)
|
||||
assert len(picked) == 4 and set(picked) <= set(cores)
|
||||
ring = sorted(picked)
|
||||
# adjacent modulo 16
|
||||
assert any(all((s + i) % 16 in picked for i in range(4)) for s in ring)
|
||||
starts.add(tuple(picked))
|
||||
assert len(starts) > 4
|
||||
|
||||
|
||||
class TestPrefsEnvIsAscii:
|
||||
def test_non_ascii_pref_round_trips(self):
|
||||
prefs = {"font.name.serif.ja": "游明朝", "intl.accept_languages": "fr-FR, fr"}
|
||||
env = utils.get_pref_env_vars(prefs)
|
||||
joined = "".join(env[f"CAMOU_PREFS_{i}"] for i in range(1, len(env) + 1))
|
||||
assert joined.isascii()
|
||||
assert orjson.loads(joined) == prefs
|
||||
Reference in New Issue
Block a user