diff --git a/README.md b/README.md index f517187..2d193c4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/additions/camoucfg/MaskConfig.hpp b/additions/camoucfg/MaskConfig.hpp index 813323f..efc90f9 100644 --- a/additions/camoucfg/MaskConfig.hpp +++ b/additions/camoucfg/MaskConfig.hpp @@ -212,18 +212,6 @@ inline std::optional> GetRect( return result; } -inline std::optional> 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 result; - std::transform(optValue->begin(), optValue->end(), result.begin(), - [](const auto& val) { return static_cast(val); }); - return result; - } - return std::nullopt; -} - // Helpers for WebGL inline std::optional GetNested(const std::string& domain, diff --git a/ci/tribal-rules.yml b/ci/tribal-rules.yml index ed8f85f..73cc5b1 100644 --- a/ci/tribal-rules.yml +++ b/ci/tribal-rules.yml @@ -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 diff --git a/native-tests/test_tribal_rules.py b/native-tests/test_tribal_rules.py index 3a0a57a..2d216a7 100644 --- a/native-tests/test_tribal_rules.py +++ b/native-tests/test_tribal_rules.py @@ -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 = { diff --git a/patches/browser-init.patch b/patches/browser-init.patch index 63ef3a8..38699d3 100644 --- a/patches/browser-init.patch +++ b/patches/browser-init.patch @@ -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'); diff --git a/patches/fingerprint-injection.patch b/patches/fingerprint-injection.patch index 406a70d..a4cdcc7 100644 --- a/patches/fingerprint-injection.patch +++ b/patches/fingerprint-injection.patch @@ -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 - - #include -@@ -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 #include -@@ -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 presContext = GetPresContextForRatio(mDoc); if (NS_WARN_IF(!presContext)) { -@@ -3594,26 +3609,38 @@ already_AddRefed 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 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 - #include - -+#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::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 - -@@ -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& aLanguages) { +@@ -128,6 +132,8 @@ void WorkerNavigator::SetLanguages(const nsTArray& 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"] diff --git a/patches/no-css-animations.patch b/patches/no-css-animations.patch index c9dfb31..8d8848d 100644 --- a/patches/no-css-animations.patch +++ b/patches/no-css-animations.patch @@ -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(); diff --git a/patches/patch-dependencies.md b/patches/patch-dependencies.md index cd2570f..1aa0d55 100644 --- a/patches/patch-dependencies.md +++ b/patches/patch-dependencies.md @@ -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` | diff --git a/patches/voice-spoofing.patch b/patches/voice-spoofing.patch index 4d4a268..170ba19 100644 --- a/patches/voice-spoofing.patch +++ b/patches/voice-spoofing.patch @@ -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(aText.Length()) / (charsPerSecond * aRate); -+ aTask->DispatchEnd(fakeElapsedTime, aText.Length()); ++ const float elapsed = ++ static_cast(aText.Length()) / (12.5f * aRate); ++ RefPtr task = aTask; ++ const uint32_t length = aText.Length(); ++ NS_DelayedDispatchToCurrentThread( ++ NS_NewRunnableFunction("CamouFakeSpeechEnd", ++ [task, elapsed, length]() { ++ (void)task->DispatchEnd(elapsed, length); ++ }), ++ static_cast(elapsed * 1000)); + return; + } + } diff --git a/pythonlib/camoufox/fpgen.yml b/pythonlib/camoufox/fpgen.yml index c3f5d33..eb31c69 100644 --- a/pythonlib/camoufox/fpgen.yml +++ b/pythonlib/camoufox/fpgen.yml @@ -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. diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index ab77595..3b63857 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -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', ) diff --git a/pythonlib/camoufox/warnings.yml b/pythonlib/camoufox/warnings.yml index 4db3968..2a51e9e 100644 --- a/pythonlib/camoufox/warnings.yml +++ b/pythonlib/camoufox/warnings.yml @@ -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. diff --git a/pythonlib/tests/test_identity_salt.py b/pythonlib/tests/test_identity_salt.py index 50ffe26..24a7cdd 100644 --- a/pythonlib/tests/test_identity_salt.py +++ b/pythonlib/tests/test_identity_salt.py @@ -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) diff --git a/pythonlib/tests/test_viewport_default.py b/pythonlib/tests/test_viewport_default.py index 70e9eb5..a14757b 100644 --- a/pythonlib/tests/test_viewport_default.py +++ b/pythonlib/tests/test_viewport_default.py @@ -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), diff --git a/settings/properties.json b/settings/properties.json index 4aee6b6..078cdab 100644 --- a/settings/properties.json +++ b/settings/properties.json @@ -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" }, diff --git a/tests/patches/animation-timing.py b/tests/patches/animation-timing.py new file mode 100644 index 0000000..eff5b35 --- /dev/null +++ b/tests/patches/animation-timing.py @@ -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("") + 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())) diff --git a/tests/patches/spoofed-voice-speaks.py b/tests/patches/spoofed-voice-speaks.py new file mode 100644 index 0000000..0723749 --- /dev/null +++ b/tests/patches/spoofed-voice-speaks.py @@ -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()))