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
+2 -3
View File
@@ -359,7 +359,7 @@ Camoufox is a Firefox fork engineered for web scraping and AI agents. It is head
* **Optimized for automation**
* Human-like mouse movement 🖱️
* Blocks & circumvents ads 🛡️
* No CSS animations 💨
* Optional instant animations (`instantAnimations`), so Playwright never waits on one 💨
- Debloated & optimized for memory efficiency ⚡
- [PyPI package](https://pypi.org/project/camoufox/) for updates & auto fingerprint injection 📦
@@ -427,7 +427,6 @@ Below is a list of patches and features implemented in Camoufox.
- Network headers (Accept-Languages and User-Agent) are spoofed to match the navigator properties
- WebRTC IP spoofing at the protocol level
- Geolocation, timezone, and locale spoofing
- Battery API spoofing
- etc.
### Stealth patches
@@ -460,7 +459,7 @@ Below is a list of patches and features implemented in Camoufox.
- Patches from LibreWolf & Ghostery to help remove telemetry & bloat
- Debloat config from PeskyFox, LibreWolf, and others
- Speed & network optimizations from FastFox
- Removed all CSS animations
- Animations run on stock timing; `instantAnimations: True` finishes them at once, at the cost of being detectable
- Minimalistic theming
- etc.
-12
View File
@@ -212,18 +212,6 @@ inline std::optional<std::array<uint32_t, 4>> GetRect(
return result;
}
inline std::optional<std::array<int32_t, 4>> GetInt32Rect(
const std::string& left, const std::string& top, const std::string& width,
const std::string& height) {
if (auto optValue = GetRect(left, top, width, height)) {
std::array<int32_t, 4> result;
std::transform(optValue->begin(), optValue->end(), result.begin(),
[](const auto& val) { return static_cast<int32_t>(val); });
return result;
}
return std::nullopt;
}
// Helpers for WebGL
inline std::optional<nlohmann::json> GetNested(const std::string& domain,
+26
View File
@@ -159,6 +159,32 @@ rules:
# Measurement
# -------------------------------------------------------------------------
- id: animations-run-on-stock-timing
title: Animations run on stock timing unless the caller opts into instantAnimations
check: automated
evidence:
- "measured on v152.0.4-beta.31: el.animate(frames, 1000).effect.getComputedTiming().duration returned 0, a 500ms transition 0"
- "the finished promise still resolved after ~1000ms, so the only saving was Playwright's stability wait"
rationale: >-
Instant animations were the default so Playwright never waited on one,
but any page reads the zero duration back in one line. Reporting the
real duration while not rendering the animation does not hide it
either: getComputedStyle or getBoundingClientRect sampled mid-animation
returns the end state. So the saving costs stealth, and it is the
caller's choice: `instantAnimations: True` turns it on and warns.
- id: spoofed-voices-speak
title: speak() on a spoofed voice starts, then ends after the text's duration
check: automated
evidence:
- "measured on v152.0.4-beta.31: speak() on a spoofed voice fired `error` after 3ms"
- "voices:fakeCompletion, the opt-out, fired start and end in the same tick"
rationale: >-
A spoofed voice has no engine behind it, and a real voice never errors
on a plain utterance or ends the instant it starts. So the browser
speaks it silently for as long as the text takes at ~150 words per
minute. The two config keys that chose between the two tells are gone.
- id: no-glyph-spacing-noise
title: Text is shaped exactly as stock Firefox shapes it; there is no spacing seed
check: automated
+13
View File
@@ -542,6 +542,19 @@ def test_a_sandbox_held_over_a_page_window_is_nuked_not_just_dropped():
)
def test_instant_animations_are_an_opt_in():
patch = (REPO_ROOT / "patches" / "no-css-animations.patch").read_text(encoding="utf-8")
assert 'MaskConfig::GetBool("instantAnimations")' in patch, explain("animations-run-on-stock-timing")
assert "disableInstantAnimations" not in patch, explain("animations-run-on-stock-timing")
def test_spoofed_voices_complete_without_a_config_switch():
patch = (REPO_ROOT / "patches" / "voice-spoofing.patch").read_text(encoding="utf-8")
assert "fakeCompletion" not in patch and "DispatchError(0, 0)" not in patch, (
explain("spoofed-voices-speak")
)
def test_no_glyph_spacing_seed_anywhere():
"""No config key, no setter, no shaper hook."""
declared = {
+11 -11
View File
@@ -1,10 +1,10 @@
diff --git a/browser/base/content/browser-init.js b/browser/base/content/browser-init.js
index 1cfa4b8497..bdf8d7202e 100644
index 0aa4baea8e..3c964b7ad5 100644
--- a/browser/base/content/browser-init.js
+++ b/browser/base/content/browser-init.js
@@ -3,6 +3,16 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@@ -44,6 +44,16 @@ var gSerialDeviceObserver = {
},
};
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs",
@@ -19,7 +19,7 @@ index 1cfa4b8497..bdf8d7202e 100644
let _resolveDelayedStartup;
var delayedStartupPromise = new Promise(resolve => {
_resolveDelayedStartup = resolve;
@@ -72,7 +82,7 @@ var gBrowserInit = {
@@ -157,7 +167,7 @@ var gBrowserInit = {
updateBookmarkToolbarVisibility();
// Set a sane starting width/height for all resolutions on new profiles.
@@ -28,7 +28,7 @@ index 1cfa4b8497..bdf8d7202e 100644
// When the fingerprinting resistance is enabled, making sure that we don't
// have a maximum window to interfere with generating rounded window dimensions.
document.documentElement.setAttribute("sizemode", "normal");
@@ -310,6 +320,22 @@ var gBrowserInit = {
@@ -385,6 +395,22 @@ var gBrowserInit = {
// Update UI if browser is under remote control.
gRemoteControl.updateVisualCue();
@@ -51,7 +51,7 @@ index 1cfa4b8497..bdf8d7202e 100644
// If we are given a tab to swap in, take care of it before first paint to
// avoid an about:blank flash.
let tabToAdopt = this.getTabToAdopt();
@@ -345,6 +371,33 @@ var gBrowserInit = {
@@ -448,6 +474,33 @@ var gBrowserInit = {
}
}
@@ -85,8 +85,8 @@ index 1cfa4b8497..bdf8d7202e 100644
// Wait until chrome is painted before executing code not critical to making the window visible
this._boundDelayedStartup = this._delayedStartup.bind(this);
window.addEventListener("MozAfterPaint", this._boundDelayedStartup);
@@ -366,9 +417,177 @@ var gBrowserInit = {
)?.removeAttribute("key");
@@ -475,9 +528,177 @@ var gBrowserInit = {
}
}
+ // Set default size
@@ -132,8 +132,8 @@ index 1cfa4b8497..bdf8d7202e 100644
+ browser.style.setProperty('box-sizing', 'content-box');
+
+ // Hijack the inner window size
+ let innerWidth = ChromeUtils.camouGetInt("window.innerWidth") || ChromeUtils.camouGetInt("document.body.clientWidth");
+ let innerHeight = ChromeUtils.camouGetInt("window.innerHeight") || ChromeUtils.camouGetInt("document.body.clientHeight");
+ let innerWidth = ChromeUtils.camouGetInt("window.innerWidth");
+ let innerHeight = ChromeUtils.camouGetInt("window.innerHeight");
+
+ if (innerWidth || innerHeight) {
+ let win_inner_style = document.createElement('style');
+27 -185
View File
@@ -1,8 +1,8 @@
diff --git a/browser/app/moz.build b/browser/app/moz.build
index a23bb7812a..58942ccb13 100644
index 28a9650680..9251ac4547 100644
--- a/browser/app/moz.build
+++ b/browser/app/moz.build
@@ -182,6 +182,9 @@ for icon in ("firefox", "document", "newwindow", "newtab", "pbmode", "document_p
@@ -186,6 +186,9 @@ for icon in ("firefox", "document", "newwindow", "newtab", "pbmode", "document_p
icon,
)
@@ -12,43 +12,11 @@ index a23bb7812a..58942ccb13 100644
if CONFIG["MOZ_ASAN"] or CONFIG["MOZ_DEBUG"]:
WINCONSOLE = True
else:
diff --git a/dom/base/Element.cpp b/dom/base/Element.cpp
index 101590d8c8..f004930b20 100644
--- a/dom/base/Element.cpp
+++ b/dom/base/Element.cpp
@@ -12,6 +12,8 @@
#include "mozilla/dom/Element.h"
+#include "MaskConfig.hpp"
+
#include <inttypes.h>
#include <cstddef>
@@ -1006,6 +1008,18 @@ nsRect Element::GetClientAreaRect() {
Document* doc = OwnerDoc();
nsPresContext* presContext = doc->GetPresContext();
+ if (doc->GetBodyElement() == this) {
+ if (auto conf = MaskConfig::GetInt32Rect(
+ "document.body.clientLeft", "document.body.clientTop",
+ "document.body.clientWidth", "document.body.clientHeight")) {
+ if (conf.has_value()) {
+ auto values = conf.value();
+ return nsRect(values[0] * 60, values[1] * 60, values[2] * 60,
+ values[3] * 60);
+ }
+ }
+ }
+
// We can avoid a layout flush if this is the scrolling element of the
// document, we have overlay scrollbars, and we aren't embedded in another
// document
diff --git a/dom/base/moz.build b/dom/base/moz.build
index cd9090cda3..37a053579e 100644
index e25ac44451..13839534ad 100644
--- a/dom/base/moz.build
+++ b/dom/base/moz.build
@@ -634,3 +634,6 @@ GeneratedFile(
@@ -647,3 +647,6 @@ GeneratedFile(
"/servo/components/style/properties/counted_unknown_properties.py",
],
)
@@ -57,10 +25,10 @@ index cd9090cda3..37a053579e 100644
+LOCAL_INCLUDES += ["/camoucfg"]
\ No newline at end of file
diff --git a/dom/base/nsGlobalWindowInner.cpp b/dom/base/nsGlobalWindowInner.cpp
index 330a707789..241f87780b 100644
index 4bc6688f9a..693779ca08 100644
--- a/dom/base/nsGlobalWindowInner.cpp
+++ b/dom/base/nsGlobalWindowInner.cpp
@@ -5,6 +5,7 @@
@@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "nsGlobalWindowInner.h"
@@ -68,7 +36,7 @@ index 330a707789..241f87780b 100644
#include <inttypes.h>
#include <stdio.h>
@@ -3439,6 +3440,8 @@ void nsGlobalWindowInner::SetName(const nsAString& aName,
@@ -3704,6 +3705,8 @@ void nsGlobalWindowInner::SetName(const nsAString& aName,
}
double nsGlobalWindowInner::GetInnerWidth(ErrorResult& aError) {
@@ -77,7 +45,7 @@ index 330a707789..241f87780b 100644
FORWARD_TO_OUTER_OR_THROW(GetInnerWidthOuter, (aError), aError, 0);
}
@@ -3450,6 +3453,8 @@ nsresult nsGlobalWindowInner::GetInnerWidth(double* aWidth) {
@@ -3715,6 +3718,8 @@ nsresult nsGlobalWindowInner::GetInnerWidth(double* aWidth) {
}
double nsGlobalWindowInner::GetInnerHeight(ErrorResult& aError) {
@@ -86,7 +54,7 @@ index 330a707789..241f87780b 100644
// We ignore aCallerType; we only have that argument because some other things
// called by GetReplaceableWindowCoord need it. If this ever changes, fix
// nsresult nsGlobalWindowInner::GetInnerHeight(double* aInnerWidth)
@@ -3466,12 +3471,18 @@ nsresult nsGlobalWindowInner::GetInnerHeight(double* aHeight) {
@@ -3731,6 +3736,12 @@ nsresult nsGlobalWindowInner::GetInnerHeight(double* aHeight) {
int32_t nsGlobalWindowInner::GetOuterWidth(CallerType aCallerType,
ErrorResult& aError) {
@@ -99,13 +67,7 @@ index 330a707789..241f87780b 100644
FORWARD_TO_OUTER_OR_THROW(GetOuterWidthOuter, (aCallerType, aError), aError,
0);
}
int32_t nsGlobalWindowInner::GetOuterHeight(CallerType aCallerType,
ErrorResult& aError) {
FORWARD_TO_OUTER_OR_THROW(GetOuterHeightOuter, (aCallerType, aError), aError,
0);
}
@@ -3486,11 +3497,13 @@ double nsGlobalWindowInner::ScreenEdgeSlopY() const {
@@ -3751,11 +3762,13 @@ double nsGlobalWindowInner::ScreenEdgeSlopY() const {
int32_t nsGlobalWindowInner::GetScreenX(CallerType aCallerType,
ErrorResult& aError) {
@@ -119,7 +81,7 @@ index 330a707789..241f87780b 100644
FORWARD_TO_OUTER_OR_THROW(GetScreenYOuter, (aCallerType, aError), aError, 0);
}
@@ -3524,6 +3537,8 @@ static nsPresContext* GetPresContextForRatio(Document* aDoc) {
@@ -3789,6 +3802,8 @@ static nsPresContext* GetPresContextForRatio(Document* aDoc) {
double nsGlobalWindowInner::GetDevicePixelRatio(CallerType aCallerType,
ErrorResult& aError) {
ENSURE_ACTIVE_DOCUMENT(aError, 0.0);
@@ -128,80 +90,20 @@ index 330a707789..241f87780b 100644
RefPtr<nsPresContext> presContext = GetPresContextForRatio(mDoc);
if (NS_WARN_IF(!presContext)) {
@@ -3594,26 +3609,38 @@ already_AddRefed<MediaQueryList> nsGlobalWindowInner::MatchMedia(
}
int32_t nsGlobalWindowInner::GetScrollMinX(ErrorResult& aError) {
+ if (auto value = MaskConfig::GetInt32("window.scrollMinX"))
+ return value.value();
FORWARD_TO_OUTER_OR_THROW(GetScrollBoundaryOuter, (eSideLeft), aError, 0);
}
int32_t nsGlobalWindowInner::GetScrollMinY(ErrorResult& aError) {
+ if (auto value = MaskConfig::GetInt32("window.scrollMinY"))
+ return value.value();
FORWARD_TO_OUTER_OR_THROW(GetScrollBoundaryOuter, (eSideTop), aError, 0);
}
int32_t nsGlobalWindowInner::GetScrollMaxX(ErrorResult& aError) {
+ if (auto value = MaskConfig::GetInt32("window.scrollMaxX"))
+ return value.value();
FORWARD_TO_OUTER_OR_THROW(GetScrollBoundaryOuter, (eSideRight), aError, 0);
}
int32_t nsGlobalWindowInner::GetScrollMaxY(ErrorResult& aError) {
+ if (auto value = MaskConfig::GetInt32("window.scrollMaxY"))
+ return value.value();
FORWARD_TO_OUTER_OR_THROW(GetScrollBoundaryOuter, (eSideBottom), aError, 0);
}
double nsGlobalWindowInner::GetScrollX(ErrorResult& aError) {
+ if (auto value = MaskConfig::GetDouble("screen.pageXOffset"))
+ return value.value();
FORWARD_TO_OUTER_OR_THROW(GetScrollXOuter, (), aError, 0);
}
double nsGlobalWindowInner::GetScrollY(ErrorResult& aError) {
+ if (auto value = MaskConfig::GetDouble("screen.pageYOffset"))
+ return value.value();
FORWARD_TO_OUTER_OR_THROW(GetScrollYOuter, (), aError, 0);
}
diff --git a/dom/base/nsHistory.cpp b/dom/base/nsHistory.cpp
index e2fd8e6389..e5ddf4e08c 100644
--- a/dom/base/nsHistory.cpp
+++ b/dom/base/nsHistory.cpp
@@ -5,6 +5,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "nsHistory.h"
+#include "MaskConfig.hpp"
#include "jsapi.h"
#include "mozilla/RefPtr.h"
@@ -64,6 +65,8 @@ JSObject* nsHistory::WrapObject(JSContext* aCx,
}
uint32_t nsHistory::GetLength(ErrorResult& aRv) const {
+ if (auto value = MaskConfig::GetUint32("window.history.length"))
+ return value.value();
nsCOMPtr<nsPIDOMWindowInner> win(do_QueryReferent(mInnerWindow));
if (!win || !win->HasActiveDocument()) {
aRv.Throw(NS_ERROR_DOM_SECURITY_ERR);
diff --git a/dom/base/nsScreen.cpp b/dom/base/nsScreen.cpp
index 306ab35772..7f7728a6cc 100644
index de9a6a4d11..78849001c5 100644
--- a/dom/base/nsScreen.cpp
+++ b/dom/base/nsScreen.cpp
@@ -6,6 +6,8 @@
@@ -4,6 +4,8 @@
#include "nsScreen.h"
+#include "MaskConfig.hpp"
+
#include "mozilla/GeckoBindings.h"
#include "mozilla/dom/BrowsingContextBinding.h"
#include "mozilla/dom/Document.h"
#include "mozilla/dom/DocumentInlines.h"
@@ -40,6 +42,10 @@ NS_IMPL_CYCLE_COLLECTION_INHERITED(nsScreen, DOMEventTargetHelper,
@@ -44,6 +46,10 @@ NS_IMPL_CYCLE_COLLECTION_INHERITED(nsScreen, DOMEventTargetHelper,
mScreenOrientation)
int32_t nsScreen::PixelDepth() {
@@ -212,7 +114,7 @@ index 306ab35772..7f7728a6cc 100644
// Return 24 to prevent fingerprinting.
if (ShouldResistFingerprinting(RFPTarget::ScreenPixelDepth)) {
return 24;
@@ -89,6 +95,12 @@ CSSIntRect nsScreen::GetRect() {
@@ -99,6 +105,12 @@ CSSIntRect nsScreen::GetRect() {
}
CSSIntRect nsScreen::GetAvailRect() {
@@ -225,79 +127,19 @@ index 306ab35772..7f7728a6cc 100644
// Return window inner rect to prevent fingerprinting.
if (ShouldResistFingerprinting(RFPTarget::ScreenAvailRect)) {
return GetTopWindowInnerRectForRFP();
diff --git a/dom/battery/BatteryManager.cpp b/dom/battery/BatteryManager.cpp
index 6322093fd9..264cc91bd1 100644
--- a/dom/battery/BatteryManager.cpp
+++ b/dom/battery/BatteryManager.cpp
@@ -9,6 +9,7 @@
#include <cmath>
#include <limits>
+#include "MaskConfig.hpp"
#include "Constants.h"
#include "mozilla/DOMEventTargetHelper.h"
#include "mozilla/Hal.h"
@@ -53,6 +54,9 @@ JSObject* BatteryManager::WrapObject(JSContext* aCx,
bool BatteryManager::Charging() const {
MOZ_ASSERT(NS_IsMainThread());
+ if (auto value = MaskConfig::GetBool("battery:charging"); value.has_value())
+ return value.value();
+
// For testing, unable to report the battery status information
if (Preferences::GetBool("dom.battery.test.default", false)) {
return true;
@@ -69,6 +73,8 @@ bool BatteryManager::Charging() const {
double BatteryManager::DischargingTime() const {
MOZ_ASSERT(NS_IsMainThread());
+ if (auto value = MaskConfig::GetDouble("battery:dischargingTime"))
+ return value.value();
// For testing, unable to report the battery status information
if (Preferences::GetBool("dom.battery.test.default", false)) {
return std::numeric_limits<double>::infinity();
@@ -86,6 +92,8 @@ double BatteryManager::DischargingTime() const {
double BatteryManager::ChargingTime() const {
MOZ_ASSERT(NS_IsMainThread());
+ if (auto value = MaskConfig::GetDouble("battery:chargingTime"))
+ return value.value();
// For testing, unable to report the battery status information
if (Preferences::GetBool("dom.battery.test.default", false)) {
return 0.0;
@@ -103,6 +111,7 @@ double BatteryManager::ChargingTime() const {
double BatteryManager::Level() const {
MOZ_ASSERT(NS_IsMainThread());
+ if (auto value = MaskConfig::GetDouble("battery:level")) return value.value();
// For testing, unable to report the battery status information
if (Preferences::GetBool("dom.battery.test.default")) {
return 1.0;
diff --git a/dom/battery/moz.build b/dom/battery/moz.build
index 3a90c93c01..91d673039b 100644
--- a/dom/battery/moz.build
+++ b/dom/battery/moz.build
@@ -21,3 +21,6 @@ FINAL_LIBRARY = "xul"
MOCHITEST_CHROME_MANIFESTS += ["test/chrome.toml"]
MOCHITEST_MANIFESTS += ["test/mochitest.toml"]
+
+# DOM Mask
+LOCAL_INCLUDES += ["/camoucfg"]
\ No newline at end of file
diff --git a/dom/workers/WorkerNavigator.cpp b/dom/workers/WorkerNavigator.cpp
index 622724b529..c9a65ff013 100644
index e9ebd3e01b..c6f64371f2 100644
--- a/dom/workers/WorkerNavigator.cpp
+++ b/dom/workers/WorkerNavigator.cpp
@@ -5,6 +5,7 @@
@@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "mozilla/dom/WorkerNavigator.h"
+#include "MaskConfig.hpp"
#include <utility>
@@ -103,6 +104,9 @@ JSObject* WorkerNavigator::WrapObject(JSContext* aCx,
#include "ErrorList.h"
#include "MainThreadUtils.h"
@@ -106,6 +107,9 @@ JSObject* WorkerNavigator::WrapObject(JSContext* aCx,
}
bool WorkerNavigator::GlobalPrivacyControl() const {
@@ -307,7 +149,7 @@ index 622724b529..c9a65ff013 100644
bool gpcStatus = StaticPrefs::privacy_globalprivacycontrol_enabled();
if (!gpcStatus) {
JSObject* jso = GetWrapper();
@@ -125,6 +129,8 @@ void WorkerNavigator::SetLanguages(const nsTArray<nsString>& aLanguages) {
@@ -128,6 +132,8 @@ void WorkerNavigator::SetLanguages(const nsTArray<nsString>& aLanguages) {
void WorkerNavigator::GetAppVersion(nsString& aAppVersion,
CallerType aCallerType,
ErrorResult& aRv) const {
@@ -316,7 +158,7 @@ index 622724b529..c9a65ff013 100644
WorkerPrivate* workerPrivate = GetCurrentThreadWorkerPrivate();
MOZ_ASSERT(workerPrivate);
@@ -147,6 +153,8 @@ void WorkerNavigator::GetAppVersion(nsString& aAppVersion,
@@ -150,6 +156,8 @@ void WorkerNavigator::GetAppVersion(nsString& aAppVersion,
void WorkerNavigator::GetPlatform(nsString& aPlatform, CallerType aCallerType,
ErrorResult& aRv) const {
@@ -325,7 +167,7 @@ index 622724b529..c9a65ff013 100644
WorkerPrivate* workerPrivate = GetCurrentThreadWorkerPrivate();
MOZ_ASSERT(workerPrivate);
@@ -207,6 +215,8 @@ class GetUserAgentRunnable final : public WorkerMainThreadRunnable {
@@ -210,6 +218,8 @@ class GetUserAgentRunnable final : public WorkerMainThreadRunnable {
void WorkerNavigator::GetUserAgent(nsString& aUserAgent, CallerType aCallerType,
ErrorResult& aRv) const {
@@ -334,7 +176,7 @@ index 622724b529..c9a65ff013 100644
WorkerPrivate* workerPrivate = GetCurrentThreadWorkerPrivate();
MOZ_ASSERT(workerPrivate);
@@ -218,6 +228,8 @@ void WorkerNavigator::GetUserAgent(nsString& aUserAgent, CallerType aCallerType,
@@ -221,6 +231,8 @@ void WorkerNavigator::GetUserAgent(nsString& aUserAgent, CallerType aCallerType,
}
uint64_t WorkerNavigator::HardwareConcurrency() const {
@@ -344,10 +186,10 @@ index 622724b529..c9a65ff013 100644
MOZ_ASSERT(rts);
diff --git a/dom/workers/moz.build b/dom/workers/moz.build
index 2f2948a729..b8ad10403f 100644
index f5ec1301a7..294a1c7a03 100644
--- a/dom/workers/moz.build
+++ b/dom/workers/moz.build
@@ -114,3 +114,6 @@ MARIONETTE_MANIFESTS += ["test/marionette/manifest.toml"]
@@ -110,3 +110,6 @@ MARIONETTE_MANIFESTS += ["test/marionette/manifest.toml"]
XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"]
BROWSER_CHROME_MANIFESTS += ["test/browser.toml"]
+10 -9
View File
@@ -1,5 +1,5 @@
diff --git a/dom/animation/AnimationEffect.cpp b/dom/animation/AnimationEffect.cpp
index e92fdc9268..5f3fa86940 100644
index e92fdc9268..9c04f00200 100644
--- a/dom/animation/AnimationEffect.cpp
+++ b/dom/animation/AnimationEffect.cpp
@@ -11,6 +11,7 @@
@@ -10,7 +10,7 @@ index e92fdc9268..5f3fa86940 100644
namespace mozilla::dom {
@@ -121,10 +122,19 @@ ComputedTiming AnimationEffect::GetComputedTimingAt(
@@ -121,10 +122,20 @@ ComputedTiming AnimationEffect::GetComputedTimingAt(
// Always return the same object to benefit from return-value optimization.
ComputedTiming result;
@@ -19,19 +19,20 @@ index e92fdc9268..5f3fa86940 100644
MOZ_ASSERT(aTiming.Duration().ref() >= zeroDuration,
"Iteration duration should be positive");
- result.mDuration = aTiming.Duration().ref();
+ // Camoufox: finite CSS animations complete instantly so Playwright doesn't
+ // wait on them. `disableInstantAnimations` restores normal animation timing.
+ if (MaskConfig::GetBool("disableInstantAnimations") ||
+ result.mActiveDuration == StickyTimeDuration::Forever()) {
+ result.mDuration = aTiming.Duration().ref();
+ } else {
+ // Camoufox: `instantAnimations` completes finite animations at once so
+ // Playwright never waits on them. Off by default: a page reads the zero
+ // duration back through getComputedTiming().
+ if (MaskConfig::GetBool("instantAnimations") &&
+ result.mActiveDuration != StickyTimeDuration::Forever()) {
+ result.mDuration = zeroDuration;
+ result.mActiveDuration = zeroDuration;
+ } else {
+ result.mDuration = aTiming.Duration().ref();
+ }
}
MOZ_ASSERT(aTiming.Iterations() >= 0.0 && !std::isnan(aTiming.Iterations()),
@@ -137,7 +147,6 @@ ComputedTiming AnimationEffect::GetComputedTimingAt(
@@ -137,7 +148,6 @@ ComputedTiming AnimationEffect::GetComputedTimingAt(
"ValidateIterationStart");
result.mIterationStart = aTiming.IterationStart();
+2 -2
View File
@@ -25,7 +25,7 @@ be listed there.
| `audio-context-spoofing.patch` | `AudioContext:outputLatency` |
| `audio-fingerprint-manager.patch` | `audio:seed` |
| `chromeutil.patch` | `debug` |
| `fingerprint-injection.patch` | `navigator.*`, `screen.*`, `window.*`, `battery:*` |
| `fingerprint-injection.patch` | `navigator.*`, `screen.*`, `window.*` |
| `font-hijacker.patch` | `navigator.platform` |
| `font-system-fonts-css2.patch` | `navigator.platform`, `window.devicePixelRatio` |
| `force-default-pointer.patch` | `navigator.maxTouchPoints` |
@@ -36,7 +36,7 @@ be listed there.
| `media-device-spoofing.patch` | `mediaDevices:*` |
| `navigator-spoofing.patch` | `navigator.*`, `timezone` |
| `network-patches.patch` | `headers.*`, `navigator.userAgent` |
| `no-css-animations.patch` | `disableInstantAnimations` |
| `no-css-animations.patch` | `instantAnimations` |
| `screen-spoofing.patch` | `screen.width`, `screen.height` |
| `system-ui-font-spoofing.patch` | `navigator.platform` |
| `timezone-spoofing.patch` | `timezone` |
+24 -27
View File
@@ -1,27 +1,29 @@
diff --git a/dom/media/webspeech/synth/moz.build b/dom/media/webspeech/synth/moz.build
index ca6ffacb21..fb661fd69e 100644
--- a/dom/media/webspeech/synth/moz.build
+++ b/dom/media/webspeech/synth/moz.build
@@ -63,2 +63,5 @@
@@ -62,3 +62,6 @@ LOCAL_INCLUDES += [
"/dom/base",
"ipc",
]
+
+# DOM Mask
+LOCAL_INCLUDES += ['/camoucfg']
diff --git a/dom/media/webspeech/synth/nsSynthVoiceRegistry.cpp b/dom/media/webspeech/synth/nsSynthVoiceRegistry.cpp
index e5a1353d6b..4a4a5b080b 100644
index 3648239545..48e195a684 100644
--- a/dom/media/webspeech/synth/nsSynthVoiceRegistry.cpp
+++ b/dom/media/webspeech/synth/nsSynthVoiceRegistry.cpp
@@ -26,7 +26,8 @@
@@ -23,7 +23,9 @@
#include "nsServiceManagerUtils.h"
#include "nsSpeechTask.h"
#include "nsString.h"
+#include "nsThreadUtils.h"
+#include "MaskConfig.hpp"
using mozilla::intl::LocaleService;
namespace mozilla::dom {
@@ -169,6 +171,20 @@
#undef LOG
@@ -165,6 +167,20 @@ nsSynthVoiceRegistry* nsSynthVoiceRegistry::GetInstance() {
// Start up all speech synth services.
NS_CreateServicesFromCategory(NS_SPEECH_SYNTH_STARTED, nullptr,
NS_SPEECH_SYNTH_STARTED);
@@ -42,7 +44,7 @@ index e5a1353d6b..4a4a5b080b 100644
}
}
@@ -305,6 +320,21 @@ nsSynthVoiceRegistry::AddVoice(nsISpeechService* aService,
@@ -301,6 +317,21 @@ nsSynthVoiceRegistry::AddVoice(nsISpeechService* aService,
return NS_ERROR_NOT_AVAILABLE;
}
@@ -64,7 +66,7 @@ index e5a1353d6b..4a4a5b080b 100644
return AddVoiceImpl(aService, aUri, aName, aLang, aLocalService,
aQueuesUtterances);
}
@@ -779,6 +796,35 @@ void nsSynthVoiceRegistry::SpeakImpl(VoiceData* aVoice, nsSpeechTask* aTask,
@@ -775,6 +806,30 @@ void nsSynthVoiceRegistry::SpeakImpl(VoiceData* aVoice, nsSpeechTask* aTask,
NS_ConvertUTF16toUTF8(aText).get(),
NS_ConvertUTF16toUTF8(aVoice->mUri).get(), aRate, aPitch));
@@ -72,26 +74,21 @@ index e5a1353d6b..4a4a5b080b 100644
+ if (auto voices = MaskConfig::MVoices()) {
+ for (const auto& [lang, name, uri, isDefault, isLocal] : voices.value()) {
+ if (NS_ConvertUTF8toUTF16(uri).Equals(aVoice->mUri)) {
+ printf_stderr("Tried to speak a fake voice: %s",
+ NS_ConvertUTF16toUTF8(aVoice->mUri).get());
+ // A spoofed voice has no engine behind it. Speak it silently for as
+ // long as the text takes at ~150 words per minute, so start and end
+ // arrive when they would from a real voice.
+ aTask->Init();
+ // If fake completion is disabled, throw an error
+ if (!MaskConfig::GetBool("voices:fakeCompletion")) {
+ aTask->DispatchError(0, 0);
+ return;
+ }
+ float charsPerSecond;
+ if (auto value =
+ MaskConfig::GetDouble("voices:fakeCompletion:charsPerSecond")) {
+ charsPerSecond = value.value();
+ } else {
+ charsPerSecond = 12.5f;
+ }
+ // Return a fake success with a speach rate of 150wpm
+ aTask->DispatchStart();
+ float fakeElapsedTime =
+ static_cast<float>(aText.Length()) / (charsPerSecond * aRate);
+ aTask->DispatchEnd(fakeElapsedTime, aText.Length());
+ const float elapsed =
+ static_cast<float>(aText.Length()) / (12.5f * aRate);
+ RefPtr<nsSpeechTask> task = aTask;
+ const uint32_t length = aText.Length();
+ NS_DelayedDispatchToCurrentThread(
+ NS_NewRunnableFunction("CamouFakeSpeechEnd",
+ [task, elapsed, length]() {
+ (void)task->DispatchEnd(elapsed, length);
+ }),
+ static_cast<uint32_t>(elapsed * 1000));
+ return;
+ }
+ }
-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),
+1 -18
View File
@@ -17,33 +17,18 @@
{ "property": "screen.width", "type": "uint" },
{ "property": "screen.colorDepth", "type": "uint" },
{ "property": "screen.pixelDepth", "type": "uint" },
{ "property": "screen.pageXOffset", "type": "double" },
{ "property": "screen.pageYOffset", "type": "double" },
{ "property": "window.scrollMinX", "type": "int" },
{ "property": "window.scrollMinY", "type": "int" },
{ "property": "window.scrollMaxX", "type": "int" },
{ "property": "window.scrollMaxY", "type": "int" },
{ "property": "window.outerHeight", "type": "uint" },
{ "property": "window.outerWidth", "type": "uint" },
{ "property": "window.innerHeight", "type": "uint" },
{ "property": "window.innerWidth", "type": "uint" },
{ "property": "window.screenX", "type": "int" },
{ "property": "window.screenY", "type": "int" },
{ "property": "window.history.length", "type": "uint" },
{ "property": "window.devicePixelRatio", "type": "double" },
{ "property": "document.body.clientWidth", "type": "uint" },
{ "property": "document.body.clientHeight", "type": "uint" },
{ "property": "document.body.clientTop", "type": "uint" },
{ "property": "document.body.clientLeft", "type": "uint" },
{ "property": "headers.User-Agent", "type": "str" },
{ "property": "headers.Accept-Language", "type": "str" },
{ "property": "headers.Accept-Encoding", "type": "str" },
{ "property": "webrtc:ipv4", "type": "str" },
{ "property": "webrtc:ipv6", "type": "str" },
{ "property": "battery:charging", "type": "bool" },
{ "property": "battery:chargingTime", "type": "double" },
{ "property": "battery:dischargingTime", "type": "double" },
{ "property": "battery:level", "type": "double" },
{ "property": "fonts", "type": "array" },
{ "property": "audio:seed", "type": "uint" },
{ "property": "geolocation:latitude", "type": "double" },
@@ -75,8 +60,6 @@
{ "property": "webGl2:contextAttributes", "type": "dict" },
{ "property": "voices", "type": "array" },
{ "property": "voices:blockIfNotDefined", "type": "bool" },
{ "property": "voices:fakeCompletion", "type": "bool" },
{ "property": "voices:fakeCompletion:charsPerSecond", "type": "double" },
{ "property": "mediaDevices:micros", "type": "uint" },
{ "property": "mediaDevices:webcams", "type": "uint" },
{ "property": "mediaDevices:speakers", "type": "uint" },
@@ -94,7 +77,7 @@
{ "property": "forceScopeAccess", "type": "bool" },
{ "property": "disableTheming", "type": "bool" },
{ "property": "disableInstantAnimations", "type": "bool" },
{ "property": "instantAnimations", "type": "bool" },
{ "property": "addons", "type": "array" },
{ "property": "certificatePaths", "type": "array" },
{ "property": "certificates", "type": "array" },
+66
View File
@@ -0,0 +1,66 @@
"""
Verify animations run on stock timing unless `instantAnimations` is set.
no-css-animations.patch can finish every finite animation at once, so Playwright
never waits on one. That used to be the default, and a page could read it back in
one line: `el.animate(frames, 1000).effect.getComputedTiming().duration` was 0
where stock Firefox reports 1000, and a CSS transition reported 0 as well. It is
now an opt-in.
What PASS means:
* by default, a 1000ms Web Animation and a 500ms CSS transition report
their real durations;
* with config {"instantAnimations": True}, both report 0.
python tests/patches/animation-timing.py
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from helpers import resolve_binary # noqa: E402
PROBE = """() => {
const animation = document.body.animate([{opacity: 0}, {opacity: 1}], 1000);
const el = document.createElement('div');
document.body.append(el);
el.style.transition = 'opacity 500ms';
el.style.opacity = '0';
el.offsetWidth;
el.style.opacity = '1';
return {
animation: animation.effect.getComputedTiming().duration,
transition: el.getAnimations().map(a => a.effect.getComputedTiming().duration),
};
}"""
async def probe(config):
from camoufox.async_api import AsyncCamoufox
async with AsyncCamoufox(headless=True, os="linux", config=config,
i_know_what_im_doing=True,
executable_path=str(resolve_binary())) as browser:
page = await browser.new_page()
await page.set_content("<body></body>")
return await page.evaluate(PROBE)
async def main() -> int:
passed = True
for config, expected in (({}, {"animation": 1000, "transition": [500]}),
({"instantAnimations": True}, {"animation": 0, "transition": [0]})):
label = "instantAnimations" if config else "default"
got = await probe(dict(config)) # the launcher fills in the dict it is given
if got == expected:
print(f" PASS {label}: {got}")
else:
passed = False
print(f" FAIL {label}: got {got}, expected {expected}")
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+78
View File
@@ -0,0 +1,78 @@
"""
Verify speechSynthesis.speak() on a spoofed voice behaves like a real voice.
A spoofed voice has no speech engine behind it. voice-spoofing.patch used to fire
an `error` event for it unless `voices:fakeCompletion` was set, and even then it
fired `start` and `end` in the same instant. A real voice never errors on a plain
utterance, and its `end` arrives after the text has been spoken. Both were tells.
What PASS means:
* the page sees spoofed voices at all (so the check is not vacuous);
* speaking 25 characters on one fires `start`, then `end`, and no `error`;
* `end` arrives about as long after `start` as the text takes to say at
~150 words per minute (2s here), not in the same tick.
python tests/patches/spoofed-voice-speaks.py
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from helpers import resolve_binary # noqa: E402
TEXT = "a" * 25 # 25 chars at 12.5 chars/s = 2.0s
PROBE = """async (text) => {
let voices = speechSynthesis.getVoices();
if (!voices.length) {
await new Promise(r => { speechSynthesis.onvoiceschanged = r; setTimeout(r, 3000); });
voices = speechSynthesis.getVoices();
}
if (!voices.length) return {voices: 0};
const u = new SpeechSynthesisUtterance(text);
u.voice = voices[0];
const events = [];
const t0 = performance.now();
const done = new Promise(resolve => {
for (const type of ['start', 'end', 'error']) {
u.addEventListener(type, () => {
events.push([type, Math.round(performance.now() - t0)]);
if (type !== 'start') resolve();
});
}
setTimeout(resolve, 10000);
});
speechSynthesis.speak(u);
await done;
return {voices: voices.length, events};
}"""
async def main() -> int:
from camoufox.async_api import AsyncCamoufox
async with AsyncCamoufox(headless=True, os="windows",
executable_path=str(resolve_binary())) as browser:
page = await browser.new_page()
await page.goto("about:blank")
got = await page.evaluate(PROBE, TEXT)
print(f" {got}")
if not got["voices"]:
print(" FAIL: the page sees no voices, so nothing was tested")
return 1
kinds = [kind for kind, _ in got["events"]]
if kinds != ["start", "end"]:
print(f" FAIL: expected start then end, got {kinds}")
return 1
spoken = got["events"][1][1] - got["events"][0][1]
if not 1500 <= spoken <= 4000:
print(f" FAIL: end came {spoken}ms after start, expected about 2000ms")
return 1
print(f" PASS: start, then end {spoken}ms later, no error")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))