You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
A list of lessons learned from my successful production integration.
#723
A quick background first on my experience to this point. It's a bit of a narrative, but I just spent 2 days hammering my head against a wall so I'm going to get some catharsis here.
About 2 years ago, I wrote a SERP scraper aimed at a certain website. It took about 2 months, and I went through a number of iterations. One of the biggest challenges was bypassing captchas. Originally, I'd attacked the problem by integrating a tool like 2captcha and capsolver. But they were ineffective, and the website would just throw an endless loop of captchas.
I spent a lot of time learning about evasion and detection at the time, so I developed an in-depth knowledge of the topic. My problem (as I'm sure the contributors to this project will know) was my automated browser leaking like a recently crashed-into fire hydrant. So my program eventually evolved from Selenium to Puppeteer. I implemented puppeteer-extra and the stealth plugin. Still not enough on its own though, but getting closer.
One of the biggest problems with Chrome-based automated browsers is that they utilise CDP for page interaction. It's one of the easiest things to detect, and it was killing me every time. Thankfully, someone was one step ahead of me, and the developer of Rebrowser provided a patched puppeteer binary with the solution to all of my problems. With Rebrowser in one hand and puppeteer-stealth-extra-plugin, I was on my way!
Pro tip: A perfectly authentic browser fingerprint is always better than busting captchas. Harder to do, but way better pay off.
The scraper ran reasonably smoothly for the last 2 years, with some minor hiccups along the way, such as the website's detection team finding a hole in one of the stealth plugin's evasions, and using that to identify users. Easily fixed by just disabling that evasion. But, as you know, all good things must come to an end, and puppeteer-extra is dead. Rebrowser is on its way too.
Now my old program is triggering captchas out of nowhere, and a week of debugging from a junior developer didn't result in a fix. Plus the whole thing was getting a bit rickety at this point, and we have new tools available to us to produce much higher quality code nowadays. I went hunting for a solution, particularly focusing on moving away from Chrome if possible (avoid the whole CDP problem outright). And I came across Camoufox.
I was very eager to make Camoufox the core of this new tool, and Claude Code would save me a huge amount of time. This time, I was coming into it with the hard won knowledge from my own experience, and a fresh architecture. I put together my design in detail (a 4500 word prompt), and sent Claude Code off to do the work. The results were impressive, and the program was production-grade right out of the gate. The main area I knew would need my intervention was the evasion configuration.
So I got into live testing of this new program, and waded through the debugging process. It was challenging in some parts, since the Camoufox/Playwright/Juggler stack is quite different from my old program. Claude Code is a real gem when it comes to this kind of work. It needs regular direction to know which areas to be checking, and what behaviours to expect from the website's detections, but it makes the testing process significantly faster and more thorough. It benefits significantly from a cloned camoufox repo for exact logic on how it works, plus points if you have the actual build process working and can generate your own patches.
Two days and nights of debugging and the result is a perfect tool.Zero detections. It's beautiful really. But it came at a cost of some real time and effort. I've asked Claude Code to compile a summary of the problems we had to solve, and the lessons we learned. Obviously I got it working, so I'm not criticising the maintainers here. And there's a good chance my use-case is not aligned with the project's intent. However, I doubt that I'm the only person with the intent of integrating Camoufox in the way I did. So I think that it may be worth addressing at least some of the issues in the following summary, since these are the challenges I ran into along the way. A lot of it just comes down to clarity for the end-user more than anything. Thank you to the maintainers and daijro for producing such a useful tool.
The following is an AI generated summary of the debugging process:
camoufox: what broke, and what would make it more reliable
Notes from building a SERP rank tracker on camoufox driven by playwright-core
from Node, August 2026. Everything below was measured against one pinned triple on
real hardware rather than inferred from source:
macOS 26.3 arm64 (real window server) and Ubuntu 24.04 under Xvfb
Where something is a guess rather than a measurement it says so. Several items are
version-specific upstream defects that may already be fixed; the point of recording
them is that each one cost hours or live requests to find, and none of them announced
itself.
1. The detection leaks
1.1 camoufox leaves its spoofing setters enumerable on window
The worst of them, and the one that produced a hard challenge on every request.
camoufox installs its per-context application path as own, enumerable, configurable [native code] functions on window: setNavigatorUserAgent, setWebGLVendor, setTimezone, setFontList, setCanvasSeed, setWebRTCIPv4 and the rest. Thirteen
were present on the build above. Any page finds them with one property read:
A stock Firefox has exactly one such key, setResizable.
The wrapper does not fix it.Camoufox() / AsyncCamoufox() calls those setters
from an add_init_script (sync_api.py:200, async_api.py:208, built at fingerprints.py:828), and the functions are documented as self-destructing when
called. Measured end to end on 0.5.4, all thirteen were still exposed afterwards
(991 own window keys), where deleting them outright leaves 978, which is stock
Firefox's shape. So the recommended integration path is the leaky one.
Suggested fix. Do not put them on window at all. The values are already applied
in C++ from CAMOU_CONFIG; the JS entry points exist for the wrapper's benefit and
are a global side channel that every page can see. If they must stay, expose them on a
ChromeOnly interface, or behind a config key that defaults to off, and make the
self-destruct actually delete the property rather than neutering the function.
1.2 Every Intl resolver drops the region
navigator.language reports en-AU while Intl.DateTimeFormat().resolvedOptions().locale
reports bare en. Measured across DateTimeFormat, Collator, NumberFormat, PluralRules,
RelativeTimeFormat, ListFormat, DisplayNames and Segmenter: all eight answer en. A
real Firefox answers en-AU from all eight. That is a one-line inconsistency for any
detector to read.
Omitting the region from the request is worse, not better, because camoufox then picks a
random region and re-rolls it per launch.
No configuration route closes it. All of these were tried and measured: intl.locale.requested, locale:all as a single value, locale:all as the
comma-separated list, and forwarding LANG / LC_ALL into the browser environment. The
last one also rules out an ICU fallback, since uloc_getDefault() would have picked the
region up from LANG.
The documentation is wrong here. camoufox.com/fingerprint/geolocation describes locale:all as "a comma-separated list of accepted locale values, with the first
matching the Intl API". On 0.5.4 the first entry does not drive the Intl locale. It
drives navigator.languages and Accept-Language only.
Suggested fix. Compose language and region when seeding ICU, so locale:language
plus locale:region reaches the Intl stack as one tag. Failing that, correct the doc,
because the current wording sends integrators down a route that cannot work.
1.3 Two Juggler defects inherited from Playwright's patch set
Both live in additions/juggler and both are readable from any page.
Sticky user activation with no input.Runtime.evaluate calls notifyUserGestureActivation() on the real page window, so any page.evaluate, and
every poll of waitForFunction, hands the page a user-activation grant that no user
produced. navigator.userActivation.hasBeenActive goes true on a page nobody touched.
We patched it to record the prior state, then roll the activation back afterwards, and
measured the tell go from present to absent with no test regressions.
Worth flagging for anyone attempting the same patch: read document.hasBeenUserGestureActivatedbefore calling setHandlingUserInput(true).
Pushing the handling-user-input state can itself mark the document activated, so
reading afterwards always sees true and the rollback silently never runs. That cost a
full build cycle to spot.
WebAssembly pinned to the baseline compiler. A fresh Debugger has allowUnobservedAsmJS and allowUnobservedWasm set to false
(js/src/debugger/Debugger.cpp:543-544). Juggler's debugger takes every content realm
as a debuggee, which forces WasmCompile.cpp:275 to build every module with debugEnabled, which pins it to baseline (the optimising tiers assert !debugEnabled
in WasmGenerator.cpp:1361) and disables the asm.js AOT path outright
(AsmJS.cpp:7176).
This needs no fingerprint and no reference machine to detect: on a real Firefox, wasm is
at least as fast as Ion-compiled JS. Measured wasm/JS timing ratio was 2.49 before the
fix and 0.21 after. Juggler never inspects wasm frames, so observing them buys nothing.
Setting both flags to true in Runtime.js and WorkerMain.js fixes it.
1.4 The font hijack breaks the Font Loading API
patches/font-hijacker.patch hijacked layout/style/FontFace.cpp and FontFaceImpl.cpp as well as the platform font list, so FontFace.status stopped
reporting real load state. A page can compare document.fonts.check() against actual
rendering and see the disagreement. We reduced the patch to the gfxPlatformFontList
allowlist only (157 lines down to 84) and the API reads correctly again.
Related, and still open for us on Linux: webfonts fail under the font allowlist, and the
allowlist cannot be removed there.
2. Surprises in how the configuration behaves
These are not leaks. They are places where the API did something reasonable-looking and
different from what a careful reader would expect, and where the cost of the
misunderstanding was high.
2.1 launch_options re-randomises 22 config keys on every call
Not the three seeds the design assumed. Measured, these come back different on each
call unless pinned:
The font and voice lists are in there, so is window.history.length, and so is navigator.globalPrivacyControl, which is generated as present-or-absent and
therefore changes the machine roughly one launch in eight.
Under a persistent cookie, an unpinned key means one returning user presenting a
different computer every session. That is a worse signal than any single fingerprint
value, and nothing in the API surface hints at it.
Suggested fix. Publish the list as a public constant so integrators can assert they
have pinned all of it, and consider making a supplied config authoritative by default,
with re-randomisation opt-in. We ended up maintaining the same list in two languages with
a test that fails if they drift, which is a workaround for something the library knows
and does not say.
2.2 launch_options(env=...) defaults to the entire parent environment
Omitting env passes os.environ itself to the browser process. During our spikes that
shipped SSH_AUTH_SOCK, CLAUDE_CODE_SESSION_ID and the developer's full PATH into
the browser. Nothing warns about it.
Suggested fix. Default to an allowlist of what the browser genuinely needs, or at
minimum to {}, and require an explicit opt-in to inherit. A browser is the last process
in a system that should receive the parent environment by default.
One thing the allowlist should name explicitly, because we hit it moving to Linux: DISPLAY and XAUTHORITY. A headful launch under xvfb-run dies with no DISPLAY environment variable specified if they are withheld, and that is exactly what happens
to anyone who follows the advice above, since xvfb-run exports DISPLAY to the
calling process and a strict allowlist then drops it before the browser is spawned.
Worth one line in the docs beside any env guidance.
2.3 Accepting a config key is not the same as applying it
navigator.doNotTrack was accepted without complaint and silently ignored. camoufox
prints unknown or unapplied keys and continues, so the launch succeeds and the value is
simply not there. In a fleet this produces an unspoofed value with no way to date the
start of it.
Suggested fix. Return a machine-readable manifest from launch_options: applied
keys, dropped keys, keys overridden by a named parameter. We had to reconstruct this by
diffing the requested config against the resolved CAMOU_CONFIG chunks, which is work
every integrator will repeat.
Related: the warnings that do exist are the wrong severity. A launch that manually sets locale, geolocation, navigator.* or screen properties prints four warnings and
proceeds. Those warnings sit next to a successful launch and are trivially lost.
Where this is already done right, and worth saying because the rest of this document
is criticism: the WebGL pair IS validated against the target platform. Moving a config
to Linux with webGl:vendor still set to Apple fails the call outright with Vendor "Apple" and renderer "Apple M1, or similar" combination not valid for Linux,
rather than resolving and producing an incoherent machine. That is the correct
behaviour, it caught a real mistake of ours during the Ubuntu run, and it is the model
the other keys should follow: refuse an impossible combination instead of accepting it
and dropping it.
2.4 The fonts key is an allowlist, not a declaration
We read fonts as "the font list this machine presents" and drew a realistic subset into
it. It actually constrains which fonts render, so the drawn subset was hiding fonts
that were installed and making the machine less consistent, not more. Once that was
understood, a planned fix became unnecessary.
Suggested fix. One sentence in the docs saying which of the two it is. The name
carries no hint, and the failure is invisible.
2.5 camoufox's own output is not safe to feed back in
webGl2:parameters["37137"] is MAX_SERVER_WAIT_TIMEOUT, a GLuint64 whose true value 2^64 - 1 is not representable in a double. Python emits the nearest double in exponent
form, 1.8446744073709552e+19, and camoufox parses that happily. Round-trip the same
config through a JSON encoder that renders it as an integer (JavaScript's JSON.stringify does) and camoufox rejects it with Error updating config: Integer exceeds 64-bit range.
So launch_options(config=<output of launch_options>) is not an identity, and whether it
works depends on which language re-encoded the JSON in between. This is a small thing that
looked, for about ten minutes, like a serious production defect.
Suggested fix. Clamp or accept values at the uint64 boundary when parsing, and treat
a float that is integral as an integer.
2.6 humanize
Three separate problems:
humanize=True sets humanize:maxTime to True as well, because isinstance(True, (int, float)) is true in Python (pythonlib/camoufox/utils.py:776).
A boolean lands where a float is expected.
At humanize=1.2 we measured two 30-second click hangs in four attempts. With
humanize off, every interaction completed under 140 ms across every run. We default it
to 0 and do our own cursor pathing.
There is a known edge deadlock where a mousemove toward exactly x == width or y == height fires as an exit event rather than eMouseMove, so the hit-renderer
acknowledgement never arrives and every later input event queues behind it forever.
The bounds check needs >=, not >.
3. An integration hazard worth documenting
This one is not a camoufox defect, but it cost more than any single defect above, and
camoufox is well placed to warn about it.
Playwright's locale context option and camoufox's locale:all both set Accept-Language. Playwright turns its option into Browser.setExtraHTTPHeaders, which replaces camoufox's header rather than merging with it. Nothing reports the conflict.
We had a diagnostic spike that passed locale to newContext and a production path that
did not, and the difference was invisible for days: the spike was silently pinned to a
different Accept-Language and a different navigator.languages from production, so
every configuration we replayed into the spike passed for the wrong reason. Finding it
took a full audit of both call sites.
Measured on loopback with identical camoufox launch options and one variable:
Accept-Language
navigator.languages
with Playwright's locale
en-AU
["en-AU"]
without it, camoufox's own value
en-AU,en;q=0.9
["en-AU","en"]
Both are legitimate Firefox states (a real Firefox 152 emits the first when configured
with en-au alone and the second when configured with en-au, en), and the request
header order is byte-identical between them, so the option varies that value and
nothing else. Which one you are actually sending is not observable from either API.
For us the difference decided the outcome, and the fix was to make camoufox's own locale:all produce the shape we wanted rather than layering Playwright's override on
top, so the config stays the single source of truth. That is the change verified 10 of 10
on Ubuntu and 10 of 10 on macOS.
The same collision exists in principle for userAgent, timezoneId, geolocation, viewport and colorScheme, all of which Playwright will happily override on top of a
camoufox spoof.
Suggested fix. Detect the Playwright-level overrides at launch and refuse or warn,
or document plainly which context options must never be passed alongside camoufox. A
short "do not set these" list in the README would have saved us the entire investigation.
Related, and already an invariant for us: viewport: null is mandatory on every
camoufox launch. Without it Playwright drives innerWidth/Height to 1280x720 while
camoufox spoofs outerWidth/Height to the real screen size, which implies a 232 px
horizontal browser chrome. That is physically impossible and free to detect. It is
mentioned upstream but deserves to be loud, because the failure is silent and looks
nothing like a configuration error.
4. What we had to build to work around all of this
Useful as a rough measure of the gap between the library and a production integration:
An init script that deletes the thirteen setters before any page script runs, plus an
in-page assertion that refuses a launch if any survive.
A list of the 22 per-launch-random keys, maintained in TypeScript and Python, with a
test that fails if they diverge, and a guard that refuses a launch unless every one is
pinned.
A resolved-config differ, so we can tell which requested keys camoufox actually applied.
A coherence probe that asserts spoofed values in-page after launch, because config
acceptance proved not to be evidence of config application.
A patched browser build carrying the two Juggler fixes and the reduced font patch.
A stateless sidecar so exactly one place in the system calls launch_options, since
calling it twice gives two different machines.
5. If only a few things changed
In the order we would rank them:
Stop exposing the setters on window. One property read defeats the whole
library, and the recommended wrapper does not prevent it.
Make a supplied config authoritative, or publish the re-randomised key list.
Silent per-launch drift is the most expensive class of bug here because it presents as
intermittent and unreproducible.
Report what was applied. A manifest of applied, dropped and overridden keys turns a
class of silent failures into an assertion integrators can write.
Fix the Intl region, or correct the documentation that says locale:all handles it.
Default env to an allowlist.
Document which Playwright context options collide with camoufox.
Land the two Juggler fixes (user-activation rollback, wasm debugger flags). Both are
a handful of lines and both are detectable from any page with no reference machine.
Items 1, 4 and 7 are detection leaks. Items 2, 3 and 5 are reliability and safety. Item 6
is documentation, and going by what it cost us, it may be the highest value per word in
the list.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
A quick background first on my experience to this point. It's a bit of a narrative, but I just spent 2 days hammering my head against a wall so I'm going to get some catharsis here.
About 2 years ago, I wrote a SERP scraper aimed at a certain website. It took about 2 months, and I went through a number of iterations. One of the biggest challenges was bypassing captchas. Originally, I'd attacked the problem by integrating a tool like 2captcha and capsolver. But they were ineffective, and the website would just throw an endless loop of captchas.
I spent a lot of time learning about evasion and detection at the time, so I developed an in-depth knowledge of the topic. My problem (as I'm sure the contributors to this project will know) was my automated browser leaking like a recently crashed-into fire hydrant. So my program eventually evolved from Selenium to Puppeteer. I implemented puppeteer-extra and the stealth plugin. Still not enough on its own though, but getting closer.
One of the biggest problems with Chrome-based automated browsers is that they utilise CDP for page interaction. It's one of the easiest things to detect, and it was killing me every time. Thankfully, someone was one step ahead of me, and the developer of Rebrowser provided a patched puppeteer binary with the solution to all of my problems. With Rebrowser in one hand and puppeteer-stealth-extra-plugin, I was on my way!
Pro tip: A perfectly authentic browser fingerprint is always better than busting captchas. Harder to do, but way better pay off.
The scraper ran reasonably smoothly for the last 2 years, with some minor hiccups along the way, such as the website's detection team finding a hole in one of the stealth plugin's evasions, and using that to identify users. Easily fixed by just disabling that evasion. But, as you know, all good things must come to an end, and puppeteer-extra is dead. Rebrowser is on its way too.
Now my old program is triggering captchas out of nowhere, and a week of debugging from a junior developer didn't result in a fix. Plus the whole thing was getting a bit rickety at this point, and we have new tools available to us to produce much higher quality code nowadays. I went hunting for a solution, particularly focusing on moving away from Chrome if possible (avoid the whole CDP problem outright). And I came across Camoufox.
I was very eager to make Camoufox the core of this new tool, and Claude Code would save me a huge amount of time. This time, I was coming into it with the hard won knowledge from my own experience, and a fresh architecture. I put together my design in detail (a 4500 word prompt), and sent Claude Code off to do the work. The results were impressive, and the program was production-grade right out of the gate. The main area I knew would need my intervention was the evasion configuration.
So I got into live testing of this new program, and waded through the debugging process. It was challenging in some parts, since the Camoufox/Playwright/Juggler stack is quite different from my old program. Claude Code is a real gem when it comes to this kind of work. It needs regular direction to know which areas to be checking, and what behaviours to expect from the website's detections, but it makes the testing process significantly faster and more thorough. It benefits significantly from a cloned camoufox repo for exact logic on how it works, plus points if you have the actual build process working and can generate your own patches.
Two days and nights of debugging and the result is a perfect tool. Zero detections. It's beautiful really. But it came at a cost of some real time and effort. I've asked Claude Code to compile a summary of the problems we had to solve, and the lessons we learned. Obviously I got it working, so I'm not criticising the maintainers here. And there's a good chance my use-case is not aligned with the project's intent. However, I doubt that I'm the only person with the intent of integrating Camoufox in the way I did. So I think that it may be worth addressing at least some of the issues in the following summary, since these are the challenges I ran into along the way. A lot of it just comes down to clarity for the end-user more than anything. Thank you to the maintainers and daijro for producing such a useful tool.
The following is an AI generated summary of the debugging process:
camoufox: what broke, and what would make it more reliable
Notes from building a SERP rank tracker on camoufox driven by
playwright-corefrom Node, August 2026. Everything below was measured against one pinned triple on
real hardware rather than inferred from source:
Where something is a guess rather than a measurement it says so. Several items are
version-specific upstream defects that may already be fixed; the point of recording
them is that each one cost hours or live requests to find, and none of them announced
itself.
1. The detection leaks
1.1 camoufox leaves its spoofing setters enumerable on
windowThe worst of them, and the one that produced a hard challenge on every request.
camoufox installs its per-context application path as own, enumerable, configurable
[native code]functions onwindow:setNavigatorUserAgent,setWebGLVendor,setTimezone,setFontList,setCanvasSeed,setWebRTCIPv4and the rest. Thirteenwere present on the build above. Any page finds them with one property read:
A stock Firefox has exactly one such key,
setResizable.The wrapper does not fix it.
Camoufox()/AsyncCamoufox()calls those settersfrom an
add_init_script(sync_api.py:200,async_api.py:208, built atfingerprints.py:828), and the functions are documented as self-destructing whencalled. Measured end to end on 0.5.4, all thirteen were still exposed afterwards
(991 own
windowkeys), where deleting them outright leaves 978, which is stockFirefox's shape. So the recommended integration path is the leaky one.
Suggested fix. Do not put them on
windowat all. The values are already appliedin C++ from
CAMOU_CONFIG; the JS entry points exist for the wrapper's benefit andare a global side channel that every page can see. If they must stay, expose them on a
ChromeOnly interface, or behind a config key that defaults to off, and make the
self-destruct actually delete the property rather than neutering the function.
1.2 Every
Intlresolver drops the regionnavigator.languagereportsen-AUwhileIntl.DateTimeFormat().resolvedOptions().localereports bare
en. Measured across DateTimeFormat, Collator, NumberFormat, PluralRules,RelativeTimeFormat, ListFormat, DisplayNames and Segmenter: all eight answer
en. Areal Firefox answers
en-AUfrom all eight. That is a one-line inconsistency for anydetector to read.
Omitting the region from the request is worse, not better, because camoufox then picks a
random region and re-rolls it per launch.
No configuration route closes it. All of these were tried and measured:
intl.locale.requested,locale:allas a single value,locale:allas thecomma-separated list, and forwarding
LANG/LC_ALLinto the browser environment. Thelast one also rules out an ICU fallback, since
uloc_getDefault()would have picked theregion up from
LANG.The documentation is wrong here. camoufox.com/fingerprint/geolocation describes
locale:allas "a comma-separated list of accepted locale values, with the firstmatching the Intl API". On 0.5.4 the first entry does not drive the Intl locale. It
drives
navigator.languagesandAccept-Languageonly.Suggested fix. Compose language and region when seeding ICU, so
locale:languageplus
locale:regionreaches the Intl stack as one tag. Failing that, correct the doc,because the current wording sends integrators down a route that cannot work.
1.3 Two Juggler defects inherited from Playwright's patch set
Both live in
additions/jugglerand both are readable from any page.Sticky user activation with no input.
Runtime.evaluatecallsnotifyUserGestureActivation()on the real page window, so anypage.evaluate, andevery poll of
waitForFunction, hands the page a user-activation grant that no userproduced.
navigator.userActivation.hasBeenActivegoes true on a page nobody touched.We patched it to record the prior state, then roll the activation back afterwards, and
measured the tell go from present to absent with no test regressions.
Worth flagging for anyone attempting the same patch: read
document.hasBeenUserGestureActivatedbefore callingsetHandlingUserInput(true).Pushing the handling-user-input state can itself mark the document activated, so
reading afterwards always sees
trueand the rollback silently never runs. That cost afull build cycle to spot.
WebAssembly pinned to the baseline compiler. A fresh
DebuggerhasallowUnobservedAsmJSandallowUnobservedWasmset tofalse(
js/src/debugger/Debugger.cpp:543-544). Juggler's debugger takes every content realmas a debuggee, which forces
WasmCompile.cpp:275to build every module withdebugEnabled, which pins it to baseline (the optimising tiers assert!debugEnabledin
WasmGenerator.cpp:1361) and disables the asm.js AOT path outright(
AsmJS.cpp:7176).This needs no fingerprint and no reference machine to detect: on a real Firefox, wasm is
at least as fast as Ion-compiled JS. Measured wasm/JS timing ratio was 2.49 before the
fix and 0.21 after. Juggler never inspects wasm frames, so observing them buys nothing.
Setting both flags to
trueinRuntime.jsandWorkerMain.jsfixes it.1.4 The font hijack breaks the Font Loading API
patches/font-hijacker.patchhijackedlayout/style/FontFace.cppandFontFaceImpl.cppas well as the platform font list, soFontFace.statusstoppedreporting real load state. A page can compare
document.fonts.check()against actualrendering and see the disagreement. We reduced the patch to the
gfxPlatformFontListallowlist only (157 lines down to 84) and the API reads correctly again.
Related, and still open for us on Linux: webfonts fail under the font allowlist, and the
allowlist cannot be removed there.
2. Surprises in how the configuration behaves
These are not leaks. They are places where the API did something reasonable-looking and
different from what a careful reader would expect, and where the cost of the
misunderstanding was high.
2.1
launch_optionsre-randomises 22 config keys on every callNot the three seeds the design assumed. Measured, these come back different on each
call unless pinned:
The font and voice lists are in there, so is
window.history.length, and so isnavigator.globalPrivacyControl, which is generated as present-or-absent andtherefore changes the machine roughly one launch in eight.
Under a persistent cookie, an unpinned key means one returning user presenting a
different computer every session. That is a worse signal than any single fingerprint
value, and nothing in the API surface hints at it.
Suggested fix. Publish the list as a public constant so integrators can assert they
have pinned all of it, and consider making a supplied
configauthoritative by default,with re-randomisation opt-in. We ended up maintaining the same list in two languages with
a test that fails if they drift, which is a workaround for something the library knows
and does not say.
2.2
launch_options(env=...)defaults to the entire parent environmentOmitting
envpassesos.environitself to the browser process. During our spikes thatshipped
SSH_AUTH_SOCK,CLAUDE_CODE_SESSION_IDand the developer's fullPATHintothe browser. Nothing warns about it.
Suggested fix. Default to an allowlist of what the browser genuinely needs, or at
minimum to
{}, and require an explicit opt-in to inherit. A browser is the last processin a system that should receive the parent environment by default.
One thing the allowlist should name explicitly, because we hit it moving to Linux:
DISPLAYandXAUTHORITY. A headful launch underxvfb-rundies withno DISPLAY environment variable specifiedif they are withheld, and that is exactly what happensto anyone who follows the advice above, since
xvfb-runexportsDISPLAYto thecalling process and a strict allowlist then drops it before the browser is spawned.
Worth one line in the docs beside any
envguidance.2.3 Accepting a config key is not the same as applying it
navigator.doNotTrackwas accepted without complaint and silently ignored. camoufoxprints unknown or unapplied keys and continues, so the launch succeeds and the value is
simply not there. In a fleet this produces an unspoofed value with no way to date the
start of it.
Suggested fix. Return a machine-readable manifest from
launch_options: appliedkeys, dropped keys, keys overridden by a named parameter. We had to reconstruct this by
diffing the requested config against the resolved
CAMOU_CONFIGchunks, which is workevery integrator will repeat.
Related: the warnings that do exist are the wrong severity. A launch that manually sets
locale,geolocation,navigator.*or screen properties prints four warnings andproceeds. Those warnings sit next to a successful launch and are trivially lost.
Where this is already done right, and worth saying because the rest of this document
is criticism: the WebGL pair IS validated against the target platform. Moving a config
to Linux with
webGl:vendorstill set toApplefails the call outright withVendor "Apple" and renderer "Apple M1, or similar" combination not valid for Linux,rather than resolving and producing an incoherent machine. That is the correct
behaviour, it caught a real mistake of ours during the Ubuntu run, and it is the model
the other keys should follow: refuse an impossible combination instead of accepting it
and dropping it.
2.4 The
fontskey is an allowlist, not a declarationWe read
fontsas "the font list this machine presents" and drew a realistic subset intoit. It actually constrains which fonts render, so the drawn subset was hiding fonts
that were installed and making the machine less consistent, not more. Once that was
understood, a planned fix became unnecessary.
Suggested fix. One sentence in the docs saying which of the two it is. The name
carries no hint, and the failure is invisible.
2.5 camoufox's own output is not safe to feed back in
webGl2:parameters["37137"]isMAX_SERVER_WAIT_TIMEOUT, a GLuint64 whose true value2^64 - 1is not representable in a double. Python emits the nearest double in exponentform,
1.8446744073709552e+19, and camoufox parses that happily. Round-trip the sameconfig through a JSON encoder that renders it as an integer (JavaScript's
JSON.stringifydoes) and camoufox rejects it withError updating config: Integer exceeds 64-bit range.So
launch_options(config=<output of launch_options>)is not an identity, and whether itworks depends on which language re-encoded the JSON in between. This is a small thing that
looked, for about ten minutes, like a serious production defect.
Suggested fix. Clamp or accept values at the uint64 boundary when parsing, and treat
a float that is integral as an integer.
2.6
humanizeThree separate problems:
humanize=Truesetshumanize:maxTimetoTrueas well, becauseisinstance(True, (int, float))is true in Python (pythonlib/camoufox/utils.py:776).A boolean lands where a float is expected.
humanize=1.2we measured two 30-second click hangs in four attempts. Withhumanize off, every interaction completed under 140 ms across every run. We default it
to 0 and do our own cursor pathing.
x == widthory == heightfires as an exit event rather thaneMouseMove, so the hit-rendereracknowledgement never arrives and every later input event queues behind it forever.
The bounds check needs
>=, not>.3. An integration hazard worth documenting
This one is not a camoufox defect, but it cost more than any single defect above, and
camoufox is well placed to warn about it.
Playwright's
localecontext option and camoufox'slocale:allboth setAccept-Language. Playwright turns its option intoBrowser.setExtraHTTPHeaders, whichreplaces camoufox's header rather than merging with it. Nothing reports the conflict.
We had a diagnostic spike that passed
localetonewContextand a production path thatdid not, and the difference was invisible for days: the spike was silently pinned to a
different
Accept-Languageand a differentnavigator.languagesfrom production, soevery configuration we replayed into the spike passed for the wrong reason. Finding it
took a full audit of both call sites.
Measured on loopback with identical camoufox launch options and one variable:
Accept-Languagenavigator.languageslocaleen-AU["en-AU"]en-AU,en;q=0.9["en-AU","en"]Both are legitimate Firefox states (a real Firefox 152 emits the first when configured
with
en-aualone and the second when configured withen-au, en), and the requestheader order is byte-identical between them, so the option varies that value and
nothing else. Which one you are actually sending is not observable from either API.
For us the difference decided the outcome, and the fix was to make camoufox's own
locale:allproduce the shape we wanted rather than layering Playwright's override ontop, so the config stays the single source of truth. That is the change verified 10 of 10
on Ubuntu and 10 of 10 on macOS.
The same collision exists in principle for
userAgent,timezoneId,geolocation,viewportandcolorScheme, all of which Playwright will happily override on top of acamoufox spoof.
Suggested fix. Detect the Playwright-level overrides at launch and refuse or warn,
or document plainly which context options must never be passed alongside camoufox. A
short "do not set these" list in the README would have saved us the entire investigation.
Related, and already an invariant for us:
viewport: nullis mandatory on everycamoufox launch. Without it Playwright drives
innerWidth/Heightto 1280x720 whilecamoufox spoofs
outerWidth/Heightto the real screen size, which implies a 232 pxhorizontal browser chrome. That is physically impossible and free to detect. It is
mentioned upstream but deserves to be loud, because the failure is silent and looks
nothing like a configuration error.
4. What we had to build to work around all of this
Useful as a rough measure of the gap between the library and a production integration:
in-page assertion that refuses a launch if any survive.
test that fails if they diverge, and a guard that refuses a launch unless every one is
pinned.
acceptance proved not to be evidence of config application.
launch_options, sincecalling it twice gives two different machines.
5. If only a few things changed
In the order we would rank them:
window. One property read defeats the wholelibrary, and the recommended wrapper does not prevent it.
configauthoritative, or publish the re-randomised key list.Silent per-launch drift is the most expensive class of bug here because it presents as
intermittent and unreproducible.
class of silent failures into an assertion integrators can write.
locale:allhandles it.envto an allowlist.a handful of lines and both are detectable from any page with no reference machine.
Items 1, 4 and 7 are detection leaks. Items 2, 3 and 5 are reliability and safety. Item 6
is documentation, and going by what it cost us, it may be the highest value per word in
the list.
All reactions