PR #562 added a `media:spoof_codecs` read on the C++ side --
MaskConfig::GetBool("media:spoof_codecs") in MP4Decoder and
MatroskaDecoder -- but never declared the key in settings/. Since
validate_config() drops any key it does not recognise, the documented
usage was inert:
AsyncCamoufox(config={"media:spoof_codecs": True})
-> "Skipping unknown patch media:spoof_codecs : True"
The key never reached the browser, so the feature could not be turned on
through the supported path at all. Declared in both properties.json and
camoucfg.jvv (bool, beside mediaDevices:enabled).
The new test is the general form rather than a check for this one key:
it scans patches/ and additions/ for MaskConfig::Get*/Has*("key") reads
and fails when a key is not declared in settings/properties.json. A
patch and its schema entry are two halves of one change, and shipping
only one half is a mistake this project has now made in both directions
-- canvas:seed (#721) and navigator.maxTouchPoints (#696) were declared
but unconsumed; this one was consumed but undeclared. Across the tree
the scan finds 63 reads against 109 declared keys, and media:spoof_codecs
was the only gap.
Note the runtime reads properties.json from the *installed browser
bundle*, not the repo, so this fix only takes effect for a build
packaged after it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
(cherry picked from commit 375b0fca4529a722220022c7993c030b83439db1)
from_preset() set userAgent, platform and oscpu from the captured device
but never appVersion. Firefox reports appVersion as "5.0 (<OS tokens>)",
so leaving it unset let the host's own value through — and a page reading
two properties saw them disagree.
Measured on 152.0.4-beta.29, macOS host, os="linux", fingerprint_preset:
navigator.platform Linux x86_64
navigator.appVersion 5.0 (Macintosh) <- the host
The value is derived from the user agent rather than from the platform,
because 20 of the 65 bundled Linux presets carry a distro token
("X11; Ubuntu") that a platform lookup would flatten to "X11" — a smaller
mismatch than the host leaking, but the same kind. Firefox builds
appVersion from the same OS tokens as the UA, minus the architecture and
the Gecko revision, with Windows collapsed to its family name; checked
against 800 browserforge fingerprints, the derivation is exact on every
one, including Android and the Ubuntu variant.
A preset that ships its own appVersion keeps it, and a user agent the
rule cannot parse leaves the key unset rather than inventing a value.
(cherry picked from commit 759e4ab2fa)
check_asset() already reads the asset's digest from the GitHub API and
stores it as installed_sha256, and AvailableVersion carries a sha256
field through to version.json. Nothing compared either against the
bytes that were downloaded: every sha256 equality check in the package
compares metadata to metadata when selecting an installed version, and
hashlib appeared only in utils.py to key a config cache.
So the archive that gets extracted over the install directory, and then
chmod 755'd and executed, was accepted on transport security alone. The
digest needed to catch a substituted or truncated asset was already in
hand and unused.
Add verify_sha256() and call it between download and extraction on both
install paths -- install_versioned() for the CLI and InstallWorker for
the GUI. It hashes in 1 MiB blocks so a multi-hundred-megabyte asset
does not have to be held in memory, and rewinds the buffer afterwards
so unzip() still reads from the start.
When no digest is published the install proceeds with a warning rather
than failing: some sources publish no digest, and refusing to install
from them would be a regression, not a fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 65cda21b4f)
maybe_download_addons() treated an addon as already downloaded whenever its
directory existed. A download that fails partway leaves an empty directory
behind, which is then trusted on every later launch, so confirm_paths()
raises InvalidAddonPath: manifest.json is missing and never recovers. Gate
the check on manifest.json presence and rmtree the partial directory on
failure. Closes#308.
(cherry picked from commit 0a8211969b)
A managed install below the version floor is upgraded by pkgman, but
executable_path deliberately bypasses that -- the caller supplied the binary,
so we neither replace it nor download another. That left one pairing nothing
checked: an old build driven by Playwright >= 1.61, which sends viewport fields
the older Juggler schema rejects. The user saw a bare
Protocol error (Browser.setDefaultViewport)
with nothing naming the cause.
Warn rather than raise, because the pairing is not always fatal. Camoufox
defaults to no_viewport when it spoofs window dimensions (sync_api), and
Playwright then never sends Browser.setDefaultViewport -- so the default path
works fine on an old build. Measured against a real beta.29 binary on
Playwright 1.62:
default path WORKS
new_context(viewport=...) BREAKS
new_context(no_viewport=False) BREAKS
new_context(viewport=..., is_mobile=False) BREAKS
Refusing to launch would break the setups in the first row. A build with no
version.json beside it -- an unpackaged objdir build -- tells us nothing, so it
is left alone rather than nagged about.
Verified end to end: warns on the real beta.29 build under Playwright 1.62,
silent on beta.30.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_env_vars() and _generate_fontconfig() read the bundled fontconfig and fonts
through get_path(), i.e. the managed install, even when the caller supplied
their own binary. _load_properties() already honours executable_path for
properties.json; these two did not.
Before the floor could reject anything this silently mixed one build's fonts
into another build's launch. Once the floor is live it becomes fatal: every
launch raises UnsupportedVersion while the caller is holding a perfectly good
binary, because resolving the bundle drags in the managed install and that is
what gets version-checked.
Thread executable_path through both, matching _load_properties.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The incompatibility is two-dimensional -- it needs both a Playwright >= 1.61
and a browser < beta.30 -- but MIN_VERSION only knows about the browser. To
stay safe a flat floor has to assume the worst Playwright, which means:
* every 0.5.6 user re-downloads the browser, including the majority on
<1.61 who are in no danger;
* installs pinned to an older build lose the pin, and prerelease/alpha users
are moved off their channel, since every alpha sorts below beta.30;
* the library cannot run at all until the matching browser release is
published, making the PyPI-after-release ordering load-bearing.
Key it on the resolved Playwright instead. Measured: 1.60 works on beta.29 and
beta.30; 1.61 and 1.62 fail on beta.29 and pass on beta.30.
playwright <1.61 -> floor alpha.1 -> every install kept
playwright >=1.61 -> floor beta.30 -> below-beta.30 installs upgraded
version unreadable -> floor alpha.1 -> kept; a spurious forced re-download is
worse than leaving a working install
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
camoufox_path() ended in `return camoufox_path()` after a fetch. When the
newest published build is still below CONSTRAINTS.MIN_VERSION, install() is a
no-op ("already installed") and that tail recursed ~1000 times -- each
iteration firing another GitHub API call, which exhausts the unauthenticated
rate limit (60/hr) long before the RecursionError lands.
That is precisely the state a library published ahead of its browser release
puts every user in, and it is reachable now that the floor is raised. It also
hits permanently for anyone using a repos.yml source that does not carry the
required build.
Re-check after the fetch instead, and raise UnsupportedVersion naming the
required minimum.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Raising CONSTRAINTS.MIN_VERSION is how this library has always forced a browser
upgrade (beta.12 -> beta.15 -> beta.17 -> beta.18 -> beta.19); the floor only
became 'alpha.1' incidentally, in an unrelated PR. That left the branch dead,
and it had rotted: camoufox_path() probed INSTALL_DIR/version.json, which only
the pre-multiversion flat layout ever wrote. With a versioned install below the
floor it raised FileNotFoundError instead of falling through to a fetch, so
raising the floor would have crashed every existing user rather than upgrading
them.
Treat a missing root version.json as "no legacy install here" so the caller
falls through to CamoufoxFetcher().install() as intended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BrowserForge picks navigator/screen; the GPU is drawn separately from
webgl_data.db weighted only by OS. Nothing ties the two together, so the
synthetic path emits pairs no real machine ships -- a discrete desktop GPU
behind a 1024x600 panel. Consistency checks (Pixelscan, Fingerprint.com) read
that as masking even though every individual value is plausible on its own.
Builds on @dyiapanis's #730, which identified the problem and the GPU-class
thresholds, with three changes:
* Constrain the GPU to the screen rather than the screen to the GPU.
sample_webgl_for_screen does rejection sampling, so the GPU keeps
webgl_data.db's real OS-weighted distribution and the geometry -- already
reconciled against the real display and the window box by
clamp_screen_to_display / fix_screen_no_taskbar / clamp_window_dimensions
/ clamp_window_position -- is left alone.
* Where no coherent GPU exists at all (BrowserForge still carries
netbook-era geometry, and nothing in the pool drives a sub-1366x768
panel), raise_screen_to_gpu_floor lifts the screen instead. It measures
the screen-to-avail gap BEFORE mutating -- #730 computed it after
overwriting screen.height, which turned a 1024x600 -> 1080 bump into a
520px "taskbar", a fresh impossible-geometry tell -- and it runs BEFORE
clamp_screen_to_display so a genuinely small monitor still wins and a
headful window cannot be pushed back off its own display (#499).
* No Apple-M Retina floor. Apple silicon also ships in the Mac mini and Mac
Studio, which drive whatever external monitor is attached, so pinning it
to 2560x1600 would reject real hardware and shrink the pool for nothing.
Measured over 300 synthetic fingerprints, incoherent GPU/screen pairs fall
from 54.3% to 0%, with avail <= screen and availHeight < height holding in
every trial. The screen floor is a no-op for the Linux and Windows pools
(0/400 draws below it) and fires on 3.5% of macOS draws, so the entropy cost
is confined to the implausible tail it exists to remove.
Co-authored-by: D Yiapanis <d@yiapanis.co>
Firefox registers the host's speech-dispatcher / SAPI / NSSpeech voices
unless something stops it, and nsSynthVoiceRegistry only stopped it when the
explicit `voices:blockIfNotDefined` flag was set. Nothing set that flag, so
the host was suppressed only as a side effect of a non-empty spoofed list --
and the Python layer built that list inside a bare `except Exception: pass`.
Any path that left the list empty or unset therefore fell through to the host
backend. On a stock Linux box that exposes 14805 espeak-ng voices to the page
under a fingerprint claiming macOS or Windows, which both leaks the real host
OS and contradicts the rest of the profile. Reproduced on 152.0.4-beta.29:
config voices exposed
generation raises 14805 (all host speechd)
{"voices": []} 14805 (all host speechd)
valid list 115 (correct)
Three changes, so the failure is closed at both layers:
* nsSynthVoiceRegistry::AddVoice now also blocks when MaskConfig carries a
`voices` array at all -- including an empty one, or one whose entries were
all rejected as malformed. An empty spoofed list must mean "no voices",
never "all of the host's". With no `voices` key the browser still behaves
like stock Firefox, so a bare binary is unaffected.
* launch_options pins `voices:blockIfNotDefined` (via set_into, so an
explicit caller value still wins) and degrades a generation failure to an
empty list rather than leaving the key unset. It also passes the spoofed
navigator.language through, so the default voice matches the locale.
* validate_voices rejects the shapes MaskConfig::MVoices() silently drops --
bare "Name:lang:type" strings and half-filled objects -- before launch
instead of letting them degrade into a host-voice leak.
Both failure paths now expose 0 voices; the normal path still exposes 115.
`VirtualDisplay.kill()` reaps the Xvfb child and then clears `self.proc`, so
asserting `vd.proc.poll() is not None` afterwards raises AttributeError on
None. Two tests failed this way on main, unrelated to any of the merged PRs.
Assert `proc is None or proc.poll() is not None` -- reaped-and-cleared is the
success path, and a surviving handle must still report an exit code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_screen_cons() was gated on DISPLAY being set, which only ever happens on
Linux, so headful runs on Windows and macOS generated fingerprints with no
monitor bound at all.
Fixes#425
screeninfo makes the process per-monitor DPI aware, so it reports physical
pixels, while Firefox lays windows out in CSS pixels. At 150% Windows scaling a
1920x1080 panel is 1280x720 CSS px, so bounding the fingerprint by the physical
size lets the window open 1.5x larger than the screen.
Refs #425
headless='virtual' reaches launch_options as headless=False with
virtual_display set (async_api rewrites it), so the headful gate fired and
clamped the fingerprint to Xvfb's 1x1 stub. fix_screen_no_taskbar then drove
availHeight to -39 and validate_config rejected the launch outright.
get_screen_cons() bounds the generated fingerprint to the monitor, but
BrowserForge honours a Screen constraint only when its pool has a match:
FingerprintGenerator.partial_csp catches the filtering failure and deletes the
constraint unless strict=True. So a 1366x768 laptop routinely gets a 2560x1440
fingerprint with window.outerWidth 1920, and browser-init resizes the real
chrome window to it -- rendering past the edge of the monitor.
Re-apply the bound after generation instead of trusting BrowserForge with it,
and pull screenX/screenY back inside the shrunken screen.
Headful only. headless has no window to overflow, and headless='virtual' runs a
1x1 Xvfb whose "monitor" would otherwise shrink the fingerprint to 1x1.
Fixes#499
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
Cover both failure modes from #656 and pin the driver entrypoint
contract, so a future Playwright reshuffle fails in CI rather than in a
user's terminal. No browser download or launch, so they run anywhere.
Refs #656
Replace the userspace lock-file scan + random-jitter retry loop with
Xvfb's own -displayfd mechanism. Xvfb scans up from :0 and atomically
binds the first free X11 socket (kernel-mediated, no userspace race),
then writes the chosen display number back through an inherited pipe.
This eliminates the duplicate-display race that occurred when many
camoufox processes started concurrently and all observed the same set
of free display numbers before any of them bound.
Adds a 10s read timeout on the displayfd pipe so a hung Xvfb fails
fast instead of blocking forever, and adds tests covering single
launch, idempotent get(), 50 concurrent reservations with uniqueness,
and post-kill display reuse.