From a80abb452aad8a992ab58adf1de245dd2c6211d9 Mon Sep 17 00:00:00 2001 From: Jake Writer Date: Sat, 5 Sep 2026 17:30:17 -0600 Subject: [PATCH] feat(patches): report a touchscreen digitizer, not a phone navigator.maxTouchPoints could already be spoofed, but nothing moved with it, so a spoofed digitizer contradicted itself in two places a script reads in one line: (any-pointer: coarse) stayed false, and window.TouchEvent and window.Touch were absent entirely. Restore the aID branch in force-default-pointer.patch so the coarse bit joins the *any-pointer* set, and only when maxTouchPoints > 0. The primary pointer stays Fine|Hover: a touchscreen laptop still drives its trackpad, and reporting (pointer: coarse) would claim a phone while the accompanying desktop UA said otherwise. The host LookAndFeel value is still not consulted -- the capability set must not vary with the machine the browser runs on. Expose the touch interfaces by moving TouchEvent::PrefEnabled only, never LegacyAPIEnabled. 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: a build that switches touch on wholesale is more detectable than one that does nothing. Rename mobile-fingerprint-spoofing.patch to touchscreen-fingerprint-spoofing .patch, since the rationale is the ordinary Windows touchscreen laptop rather than a phone, and carry the new TouchEvent.cpp hunk there beside the existing Navigator.cpp one. The rename moves it after navigator-spoofing.patch in basename order, so its Navigator.cpp hunk now lands with an offset; verified to still apply cleanly with no rejects. Warn at launch whenever navigator.maxTouchPoints is set, separately from the blanket navigator warning, because the knock-on effects reach past navigator into the CSS pointer media queries and the TouchEvent interfaces. tests/patches/touchscreen-digitizer.py checks all 16 signals and asserts that maxTouchPoints=0 still looks like a machine with no digitizer. It fails on a binary built without this change (13/16) and passes on one built with it. The reference values it carries are RECONSTRUCTED, not captured: the recording from the Dell XPS 15 9510 was not reachable from the build host, so eight values come from the specification and eight from Gecko's own gating logic. Each is marked in the table. Check them against the real capture when the reference machine is available; the capture wins. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W2RfR387Mh1JhZ9LZvkptP --- patches/force-default-pointer.patch | 46 +++- patches/mobile-fingerprint-spoofing.patch | 17 -- .../touchscreen-fingerprint-spoofing.patch | 81 ++++++ pythonlib/camoufox/utils.py | 5 + pythonlib/camoufox/warnings.yml | 10 +- tests/patches/touchscreen-digitizer.py | 230 ++++++++++++++++++ 6 files changed, 361 insertions(+), 28 deletions(-) delete mode 100644 patches/mobile-fingerprint-spoofing.patch create mode 100644 patches/touchscreen-fingerprint-spoofing.patch create mode 100644 tests/patches/touchscreen-digitizer.py 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()))