fix: stock animations and speech by default; drop config keys that freeze live values

Three behaviours a page could detect, changed in one breaking release:

- **Animations run on stock timing.** no-css-animations.patch finished every
  finite animation at once by default, and any page could read it:
  `el.animate(frames, 1000).effect.getComputedTiming().duration` was 0, and a
  500ms transition reported 0. Measured on v152.0.4-beta.31. The speedup is
  now an opt-in, `instantAnimations: True`, which raises a LeakWarning.
  disableInstantAnimations is gone.
- **speak() on a spoofed voice works like a real voice.** It fired `error`
  after 3ms unless voices:fakeCompletion was set, and then start and end in
  the same tick. It now starts and ends after the text's duration at ~150
  words per minute. Both voices:fakeCompletion keys are gone, and so is a
  debug line printed to stderr on every call.
- **Keys removed:**
  - battery:* and window.scrollMinX/Y: Firefox keeps getBattery() and
    scrollMin* chrome-only, so no page could read them.
  - window.scrollMaxX/Y, screen.pageXOffset/pageYOffset,
    window.history.length and document.body.client*: each pinned a live value
    to a constant, so scrolling, navigating or re-laying out never changed it.
    fpgen.yml mapped pageYOffset, so about 15% of identities froze
    window.scrollY at a non-zero value.
  - The body keys' role as an undocumented alias for window.innerWidth/Height
    in browser-init and in the launcher.
  - MaskConfig::GetInt32Rect, which only the body keys used.

New guards, both of which fail on v152.0.4-beta.31:
tests/patches/animation-timing.py and tests/patches/spoofed-voice-speaks.py.
The decisions are recorded as animations-run-on-stock-timing and
spoofed-voices-speak. Every patch applies cleanly to a fresh tree, and the
result builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-09-25 20:33:21 -06:00
co-authored by Claude Opus 5.5
parent 676fb3f7c1
commit fb21b2e35a
17 changed files with 274 additions and 271 deletions
-1
View File
@@ -59,7 +59,6 @@ window:
# bottom edge land (see BROWSER_CHROME_HEIGHT in coherence.py).
screenX: window.screenX
screenY: window.screenY
pageYOffset: screen.pageYOffset
# devicePixelRatio is not mapped: any value but 1 is a spoofing tell unless
# the whole geometry is scaled with it.
+2 -2
View File
@@ -575,6 +575,8 @@ def warn_manual_config(config: Dict[str, Any]) -> None:
# CSS pointer media queries and the TouchEvent interfaces.
if is_domain_set(config, 'navigator.maxTouchPoints'):
LeakWarning.warn('max_touch_points', False)
if config.get('instantAnimations'):
LeakWarning.warn('instant_animations', False)
# Manual screen/window setting
if is_domain_set(config, 'screen.', 'window.', 'document.body.'):
LeakWarning.warn('viewport', False)
@@ -585,8 +587,6 @@ _WINDOW_DIM_KEYS = (
'window.outerHeight',
'window.innerWidth',
'window.innerHeight',
'document.body.clientWidth',
'document.body.clientHeight',
)
+5
View File
@@ -36,6 +36,11 @@ no_region: >-
Because you did not pass in a locale region, Camoufox will generate one for you.
This can cause suspicion if your IP does not match your locale region.
instant_animations: >-
instantAnimations makes every finite animation finish at once, so Playwright
never waits on one. A page can see it: getComputedTiming() reports a duration
of 0 where stock Firefox reports the real one.
block_webgl: >-
Disabling WebGL is not recommended. Many WAFs will check if WebGL is enabled.
+7
View File
@@ -194,3 +194,10 @@ def test_config_overrides_reach_the_config_and_the_init_script():
context = fp.generate_context_fingerprint(os="linux", config_overrides={"audio:seed": 7})
assert context["config"]["audio:seed"] == 7
assert "setAudioFingerprintSeed(7)" in context["init_script"]
def test_instant_animations_warn_that_they_are_detectable():
from camoufox._warnings import LeakWarning
with pytest.warns(LeakWarning, match="getComputedTiming"):
launch(config={"instantAnimations": True}, i_know_what_im_doing=False)
-1
View File
@@ -22,7 +22,6 @@ def _opts(config_blob: str):
[
('{"window.outerWidth": 360}', True),
('{"window.innerHeight": 740}', True),
('{"document.body.clientWidth": 360}', True),
('{"screen.width": 360}', False),
('{"navigator.userAgent": "x"}', False),
("{}", False),