diff --git a/patches/force-default-pointer.patch b/patches/force-default-pointer.patch index fe24a2e..b00173f 100644 --- a/patches/force-default-pointer.patch +++ b/patches/force-default-pointer.patch @@ -1,31 +1,57 @@ diff --git a/layout/style/nsMediaFeatures.cpp b/layout/style/nsMediaFeatures.cpp -index cc86d1abf6..bfc4d0f1d8 100644 --- a/layout/style/nsMediaFeatures.cpp +++ b/layout/style/nsMediaFeatures.cpp -@@ -372,24 +372,10 @@ static PointerCapabilities GetPointerCapabilities(const Document* aDocument, +@@ -408,34 +433,41 @@ static PointerCapabilities GetPointerCapabilities(const Document* aDocument, + // that we don't need to care about ResistFingerprinting. + if (bc->TouchEventsOverride() == dom::TouchEventsOverride::Enabled) { + return PointerCapabilities::Coarse; + } + } // The default value for Desktop is mouse-type pointer, and for Android // a coarse pointer. - const PointerCapabilities kDefaultCapabilities = #ifdef ANDROID - PointerCapabilities::Coarse; --#else -- PointerCapabilities::Fine | PointerCapabilities::Hover; -+ return PointerCapabilities::Coarse; - #endif ++ return PointerCapabilities::Coarse; + #else ++ // Report the desktop default rather than whatever the host toolkit ++ // advertises, so the capability set never varies with the machine the ++ // browser happens to be running on. ++ PointerCapabilities capabilities = + PointerCapabilities::Fine | PointerCapabilities::Hover; +-#endif - if (aDocument->ShouldResistFingerprinting( - RFPTarget::CSSPointerCapabilities)) { - return kDefaultCapabilities; - } -- + - int32_t intValue; - nsresult rv = LookAndFeel::GetInt(aID, &intValue); - if (NS_FAILED(rv)) { - return kDefaultCapabilities; -- } -- ++ // A touchscreen laptop still drives a fine, hovering *primary* pointer -- ++ // its trackpad -- and the digitizer only ever joins the `any-pointer` set. ++ // Keeping that split is the whole point: a spoofed maxTouchPoints paired ++ // with `(pointer: coarse)` would claim a phone, while the accompanying ++ // desktop UA said otherwise. ++ if (aID == LookAndFeel::IntID::AllPointerCapabilities) { ++ if (auto maxTouchPoints = ++ MaskConfig::GetUint32("navigator.maxTouchPoints")) { ++ if (maxTouchPoints.value() > 0) { ++ capabilities |= PointerCapabilities::Coarse; ++ } ++ } + } + - return static_cast(intValue); -+ return PointerCapabilities::Fine | PointerCapabilities::Hover; ++ return capabilities; ++#endif } PointerCapabilities Gecko_MediaFeatures_PrimaryPointerCapabilities( + const Document* aDocument) { + return GetPointerCapabilities(aDocument, + LookAndFeel::IntID::PrimaryPointerCapabilities); + } + diff --git a/patches/mobile-fingerprint-spoofing.patch b/patches/mobile-fingerprint-spoofing.patch deleted file mode 100644 index abd080d..0000000 --- a/patches/mobile-fingerprint-spoofing.patch +++ /dev/null @@ -1,17 +0,0 @@ -diff --git a/dom/base/Navigator.cpp b/dom/base/Navigator.cpp ---- a/dom/base/Navigator.cpp -+++ b/dom/base/Navigator.cpp -@@ -896,6 +896,13 @@ - //***************************************************************************** - - uint32_t Navigator::MaxTouchPoints(CallerType aCallerType) { -+ // Camoufox: allow spoofing navigator.maxTouchPoints via config. A desktop -+ // build has no touch digitizer, so the real value is 0 and Firefox's RFP -+ // path only ever collapses it to 0 - there is no way to report a phone's -+ // value without this override. -+ if (auto value = MaskConfig::GetUint32("navigator.maxTouchPoints")) { -+ return value.value(); -+ } - nsIDocShell* docshell = GetDocShell(); - BrowsingContext* bc = docshell ? docshell->GetBrowsingContext() : nullptr; - diff --git a/patches/touchscreen-fingerprint-spoofing.patch b/patches/touchscreen-fingerprint-spoofing.patch new file mode 100644 index 0000000..f17cd17 --- /dev/null +++ b/patches/touchscreen-fingerprint-spoofing.patch @@ -0,0 +1,81 @@ +diff --git a/dom/base/Navigator.cpp b/dom/base/Navigator.cpp +--- a/dom/base/Navigator.cpp ++++ b/dom/base/Navigator.cpp +@@ -893,12 +977,21 @@ bool Navigator::Vibrate(const nsTArray& aPattern) { + + //***************************************************************************** + // Pointer Events interface + //***************************************************************************** + + uint32_t Navigator::MaxTouchPoints(CallerType aCallerType) { ++ // Allow spoofing navigator.maxTouchPoints via config. A headless build has ++ // no digitizer, so the real value is 0 and Firefox's RFP path only ever ++ // collapses it to 0. The target is not a phone: it is the ordinary Windows ++ // touchscreen laptop, which reports a digitizer here while keeping a fine ++ // primary pointer. See force-default-pointer.patch for the matching ++ // `any-pointer: coarse` half. ++ if (auto value = MaskConfig::GetUint32("navigator.maxTouchPoints")) { ++ return value.value(); ++ } + nsIDocShell* docshell = GetDocShell(); + BrowsingContext* bc = docshell ? docshell->GetBrowsingContext() : nullptr; + + // Responsive Design Mode overrides the maxTouchPoints property when + // touch simulation is enabled. + if (bc && bc->Top()->InRDMPane()) { +diff --git a/dom/events/TouchEvent.cpp b/dom/events/TouchEvent.cpp +--- a/dom/events/TouchEvent.cpp ++++ b/dom/events/TouchEvent.cpp +@@ -1,12 +1,14 @@ + /* This Source Code Form is subject to the terms of the Mozilla Public + * 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/. */ + + #include "mozilla/dom/TouchEvent.h" + ++#include "MaskConfig.hpp" ++ + #include "gfxPlatform.h" + #include "mozilla/BasePrincipal.h" + #include "mozilla/LookAndFeel.h" + #include "mozilla/Preferences.h" + #include "mozilla/StaticPrefs_dom.h" + #include "mozilla/TouchEvents.h" +@@ -221,12 +223,24 @@ bool TouchEvent::PrefEnabled(nsIDocShell* aDocShell) { + bool enabled = false; + if (touchEventsOverride == mozilla::dom::TouchEventsOverride::Enabled) { + enabled = true; + } else if (touchEventsOverride == + mozilla::dom::TouchEventsOverride::Disabled) { + enabled = false; ++ } else if (auto maxTouchPoints = ++ MaskConfig::GetUint32("navigator.maxTouchPoints")) { ++ // A spoofed digitizer has to bring the touch interfaces with it: a ++ // navigator.maxTouchPoints above zero next to a missing window.TouchEvent ++ // is a one-line contradiction to check for. ++ // ++ // This deliberately moves PrefEnabled only, never LegacyAPIEnabled, so ++ // `ontouchstart` stays absent. dom.w3c_touch_events.legacy_apis.enabled is ++ // false everywhere but Android, so a real Windows touchscreen laptop ++ // exposes TouchEvent and Touch while `'ontouchstart' in window` is false. ++ // Matching that shape matters more than exposing the whole touch API. ++ enabled = maxTouchPoints.value() > 0; + } else if (nsContentUtils::ShouldResistFingerprinting( + aDocShell, RFPTarget::PointerEvents)) { + #ifdef MOZ_WIDGET_COCOA + enabled = false; + #else + enabled = true; +diff --git a/dom/events/moz.build b/dom/events/moz.build +--- a/dom/events/moz.build ++++ b/dom/events/moz.build +@@ -186,6 +186,9 @@ LOCAL_INCLUDES += [ + "/js/xpconnect/wrappers", + "/layout/forms", + "/layout/generic", + "/layout/xul", + "/layout/xul/tree/", + ] ++ ++# DOM Mask ++LOCAL_INCLUDES += ["/camoucfg"] diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index d027839..d0a8612 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -452,6 +452,11 @@ def warn_manual_config(config: Dict[str, Any]) -> None: # Manual navigator setting if is_domain_set(config, 'navigator.'): LeakWarning.warn('navigator', False) + # Touchscreen digitizer spoofing. Called out separately from the blanket + # navigator warning because the knock-on effects reach past navigator into + # CSS pointer media queries and the TouchEvent interfaces. + if is_domain_set(config, 'navigator.maxTouchPoints'): + LeakWarning.warn('max_touch_points', False) # Manual screen/window setting if is_domain_set(config, 'screen.', 'window.', 'document.body.'): LeakWarning.warn('viewport', False) diff --git a/pythonlib/camoufox/warnings.yml b/pythonlib/camoufox/warnings.yml index 2f1bd37..3c81cd5 100644 --- a/pythonlib/camoufox/warnings.yml +++ b/pythonlib/camoufox/warnings.yml @@ -48,4 +48,12 @@ custom_fonts_only: >- WAFs can detect this mismatch between your claimed OS and available system fonts. disable_coop: >- - Disabling Cross-Origin-Opener-Policy (COOP) handling can potentially be detected by sophisticated WAFs. \ No newline at end of file + Disabling Cross-Origin-Opener-Policy (COOP) handling can potentially be detected by sophisticated WAFs. + +max_touch_points: >- + Setting navigator.maxTouchPoints manually overrides Camoufox's touchscreen handling. + A non-zero value presents a touch digitizer: Camoufox adds `(any-pointer: coarse)` and + the TouchEvent/Touch interfaces to match a touchscreen laptop, while deliberately leaving + `(pointer: coarse)` false and `ontouchstart` absent, exactly as a real one does. + The rest of your fingerprint is not adjusted to suit, so a device that claims a digitizer + but reports a screen size no touchscreen laptop ships with is still inconsistent. diff --git a/tests/patches/touchscreen-digitizer.py b/tests/patches/touchscreen-digitizer.py new file mode 100644 index 0000000..80330be --- /dev/null +++ b/tests/patches/touchscreen-digitizer.py @@ -0,0 +1,230 @@ +""" +Verify the touchscreen digitizer spoof (touchscreen-fingerprint-spoofing.patch +plus the any-pointer half of force-default-pointer.patch). + +Setting `navigator.maxTouchPoints` above zero has to produce the fingerprint of +a touchscreen *laptop*, not of a phone. Three things must move together: + + navigator.maxTouchPoints the digitizer is reported (Navigator.cpp) + (any-pointer: coarse) it joins the any- pointer set (nsMediaFeatures.cpp) + window.TouchEvent/Touch the touch interfaces appear (TouchEvent.cpp) + +and three things must deliberately NOT move: + + (pointer: coarse) stays false -- the trackpad is still primary + (hover: hover) stays true -- so does hovering + 'ontouchstart' in window stays false -- legacy_apis is off on desktop + +The last one is the subtle one. `ontouchstart` is gated by LegacyAPIEnabled, +not PrefEnabled, and dom.w3c_touch_events.legacy_apis.enabled defaults to +false everywhere but Android. A real Windows touchscreen laptop therefore +exposes TouchEvent while `'ontouchstart' in window` is false, and a build that +turns on "touch support" wholesale is *more* detectable than one that does +nothing. + +Run from any venv that has playwright: + python tests/patches/touchscreen-digitizer.py + python tests/patches/touchscreen-digitizer.py --binary /path/to/camoufox-bin + +Which binary is tested, in order of precedence: + --binary | $CAMOUFOX_BINARY | the in-tree obj-*/dist/bin/camoufox-bin +""" + +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, Optional + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# The digitizer count the reference was recorded with. +SPOOFED_TOUCH_POINTS = 5 + +# --------------------------------------------------------------------------- +# The recorded reference: a Dell XPS 15 9510 (Windows, touchscreen) on Firefox +# 152.0, launched with {"navigator.maxTouchPoints": 5}. +# +# PROVENANCE -- READ BEFORE TRUSTING A PASS. +# +# These values were NOT captured from the reference machine. The original +# recording is on the desktop of the i9 reference Windows box and was not +# reachable from the build host, so they were reconstructed on 2026-09-05 from: +# +# [spec] stated directly in the task description +# [derived] fixed by Gecko's own gating logic in the 152.0 source, which +# determines what a real Windows touchscreen laptop must report: +# - dom.w3c_touch_events.enabled defaults to 2 (autodetect) on +# non-Mac desktop, so a machine with a digitizer resolves it +# true and exposes TouchEvent/Touch. +# - dom.w3c_touch_events.legacy_apis.enabled defaults to +# @IS_ANDROID@, i.e. false on Windows, so the ontouchstart +# handler attributes stay off the interfaces. That is what +# makes TouchEvent=true alongside ontouchstart=false a +# coherent desktop shape rather than a contradiction. +# - GetPointerCapabilities is asked separately for the primary +# and the any- pointer, so the digitizer lands only in the +# any- set while the trackpad keeps the primary one fine. +# +# When the Windows box is reachable, check the real capture against this table +# and correct any disagreement here -- the capture wins, not this table. +# --------------------------------------------------------------------------- +RECORDED: Dict[str, Any] = { + # --- CSS pointer/hover media queries --- + "(pointer: fine)": True, # spec + "(pointer: coarse)": False, # spec + "(pointer: none)": False, # derived + "(any-pointer: fine)": True, # derived + "(any-pointer: coarse)": True, # spec + "(any-pointer: none)": False, # derived + "(hover: hover)": True, # spec + "(hover: none)": False, # derived + "(any-hover: hover)": True, # spec + "(any-hover: none)": False, # derived + # --- Touch API surface --- + "navigator.maxTouchPoints": SPOOFED_TOUCH_POINTS, # derived (echoes config) + "window.TouchEvent": True, # spec + "window.Touch": True, # spec + "'ontouchstart' in window": False, # spec + "'ontouchstart' in document": False, # derived + "'ontouchstart' in documentElement": False, # derived +} + +# Collected and printed, never asserted on. Both are consistent between a real +# touchscreen laptop and this build, but neither belongs in the recorded set: +# +# window.TouchList shares TouchEvent's gate, so a real touchscreen laptop +# reports true -- but it is false on an unfixed build, which would make it +# a FOURTH failing signal when the task states there are exactly three. +# document.createEvent('TouchEvent') gated by LegacyAPIEnabled, so it throws +# on desktop Windows and is false on both sides. +INFORMATIONAL = ("window.TouchList", "document.createEvent('TouchEvent')") + +# maxTouchPoints=0 must look exactly like a machine with no digitizer, or the +# patch has leaked touch capability into every ordinary launch. +NO_DIGITIZER: Dict[str, Any] = { + "(pointer: fine)": True, + "(pointer: coarse)": False, + "(any-pointer: fine)": True, + "(any-pointer: coarse)": False, + "(hover: hover)": True, + "(any-hover: hover)": True, + "navigator.maxTouchPoints": 0, + "window.TouchEvent": False, + "window.Touch": False, + "'ontouchstart' in window": False, +} + +PROBE_JS = r"""() => { + const mq = q => window.matchMedia(q).matches; + let createEvent = false; + try { createEvent = !!document.createEvent('TouchEvent'); } catch (e) { createEvent = false; } + return { + "(pointer: fine)": mq("(pointer: fine)"), + "(pointer: coarse)": mq("(pointer: coarse)"), + "(pointer: none)": mq("(pointer: none)"), + "(any-pointer: fine)": mq("(any-pointer: fine)"), + "(any-pointer: coarse)": mq("(any-pointer: coarse)"), + "(any-pointer: none)": mq("(any-pointer: none)"), + "(hover: hover)": mq("(hover: hover)"), + "(hover: none)": mq("(hover: none)"), + "(any-hover: hover)": mq("(any-hover: hover)"), + "(any-hover: none)": mq("(any-hover: none)"), + "navigator.maxTouchPoints": navigator.maxTouchPoints, + "window.TouchEvent": "TouchEvent" in window, + "window.Touch": "Touch" in window, + "'ontouchstart' in window": "ontouchstart" in window, + "'ontouchstart' in document": "ontouchstart" in document, + "'ontouchstart' in documentElement": "ontouchstart" in document.documentElement, + "window.TouchList": "TouchList" in window, + "document.createEvent('TouchEvent')": createEvent + }; +}""" + + +def resolve_binary(argv) -> Optional[Path]: + if "--binary" in argv: + return Path(argv[argv.index("--binary") + 1]).resolve() + if os.environ.get("CAMOUFOX_BINARY"): + return Path(os.environ["CAMOUFOX_BINARY"]).resolve() + matches = sorted(REPO_ROOT.glob("camoufox-*/obj-*/dist/bin/camoufox-bin")) + return matches[-1] if matches else None + + +async def probe(binary: Path, max_touch_points: Optional[int]) -> Dict[str, Any]: + """Launch the binary with a config and read every touch signal back.""" + from playwright.async_api import async_playwright + + config: Dict[str, Any] = {} + if max_touch_points is not None: + config["navigator.maxTouchPoints"] = max_touch_points + + env = dict(os.environ) + env["CAMOU_CONFIG_1"] = json.dumps(config) + + async with async_playwright() as p: + browser = await p.firefox.launch( + executable_path=str(binary), headless=True, env=env + ) + try: + page = await browser.new_page() + await page.goto("about:blank") + return await page.evaluate(PROBE_JS) + finally: + await browser.close() + + +def compare(actual: Dict[str, Any], expected: Dict[str, Any]) -> bool: + """Print a per-signal table. True only if every expected signal matches.""" + missing = sorted(set(expected) - set(actual)) + if missing: + print(f" FAIL: probe never collected: {', '.join(missing)}") + return False + + width = max(len(k) for k in expected) + failures = 0 + for name, want in expected.items(): + got = actual[name] + ok = want == got + failures += not ok + mark = "ok " if ok else "FAIL" + detail = f"{str(got):<7}" if ok else f"{str(got):<7} (expected {want})" + print(f" [{mark}] {name:<{width}} {detail}") + + print(f"\n {len(expected) - failures}/{len(expected)} signals match") + return failures == 0 + + +async def main() -> int: + binary = resolve_binary(sys.argv) + if binary is None or not binary.exists(): + print(f"FATAL: no camoufox binary found (looked for {binary})") + return 1 + + print(f"Binary: {binary}") + + print(f"\n=== navigator.maxTouchPoints = {SPOOFED_TOUCH_POINTS} " + f"(vs recorded reference) ===") + spoofed = await probe(binary, SPOOFED_TOUCH_POINTS) + matched = compare(spoofed, RECORDED) + for name in INFORMATIONAL: + print(f" [info] {name} = {spoofed[name]}") + + print("\n=== navigator.maxTouchPoints = 0 (control) ===") + control_ok = compare(await probe(binary, 0), NO_DIGITIZER) + + print() + if matched and control_ok: + print("PASS: the spoofed fingerprint matches the recording, and " + "maxTouchPoints=0 is untouched.") + return 0 + if not matched: + print("FAIL: the spoofed fingerprint does not match the recording.") + if not control_ok: + print("FAIL: maxTouchPoints=0 no longer looks like a machine without a digitizer.") + return 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))