Merge pull request #756 from JWriter20/integration/triage-2026-09-05

Integration branch: eleven open PRs built and verified together on FF152
This commit is contained in:
Jake Writer
2026-09-06 01:13:45 +00:00
committed by GitHub
44 changed files with 2769 additions and 1703 deletions
+21
View File
@@ -0,0 +1,21 @@
name: Lint
# Static checks only -- no browser build, so these can gate every pull request.
# The build workflow runs on tags and takes ~40 minutes; nothing was checking
# pull requests before this.
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
jobs:
input-dispatch:
name: Synthesized input goes through one chokepoint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python3 scripts/check-input-dispatch.py
+26
View File
@@ -7,6 +7,7 @@ Written by daijro.
#include "json.hpp"
#include <memory>
#include <string>
#include <string_view>
#include <tuple>
#include <optional>
#include <codecvt>
@@ -116,6 +117,31 @@ inline std::vector<std::string> GetStringListLower(const std::string& key) {
return result;
}
/**
* The spoofed font family allowlist ("fonts"), lowercased and cached for the
* lifetime of the process. CAMOU_CONFIG is read once at startup and never
* changes, and the gfx font lookup paths consult this on every family
* resolution, so re-parsing the JSON per call is not an option.
* An empty list means no font spoofing is configured.
*/
inline const std::vector<std::string>& FontAllowlist() {
static const std::vector<std::string> fonts = GetStringListLower("fonts");
return fonts;
}
inline bool HasFontAllowlist() { return !FontAllowlist().empty(); }
/**
* Whether a font family may be used. `family` must already be lowercased
* (gfxPlatformFontList::GenerateFontListKey output is). Always true when no
* allowlist is configured.
*/
inline bool IsFontAllowed(std::string_view family) {
const auto& fonts = FontAllowlist();
if (fonts.empty()) return true;
return std::find(fonts.begin(), fonts.end(), family) != fonts.end();
}
template <typename T>
inline std::optional<T> GetUintImpl(const std::string& key) {
const auto& data = GetJson();
+32
View File
@@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
const {setTimeout, clearTimeout} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
export class Helper {
decorateAsEventEmitter(objectToDecorate) {
@@ -172,6 +173,8 @@ export class Helper {
const helper = new Helper();
const kEventTimedOut = Symbol('event-timed-out');
export class EventWatcher {
constructor(receiver, eventNames, pendingEventWatchers = new Set()) {
this._pendingEventWatchers = pendingEventWatchers;
@@ -202,6 +205,35 @@ export class EventWatcher {
}
}
/**
* Like ensureEvent, but gives up after timeoutMs and resolves null instead of
* waiting forever.
*
* Callers awaiting an ack for synthesized input must use this. Input dispatch
* is serialized on activateAndRun()'s process-global promise chain, so an ack
* that never arrives does not merely lose one event -- it wedges every later
* input event in the process, in every tab, permanently, at 0% CPU with no
* diagnostic. Four shipped deadlocks (#225, #677, #751, #752) were all that
* failure. Bounding the wait is what makes the fifth one a log line.
*/
async ensureEventWithin(aEventName, timeoutMs, predicate) {
const pending = this.ensureEvent(aEventName, predicate);
// Whichever promise loses the race stays pending until dispose() rejects
// it; swallow that so a timed-out wait never surfaces as an unhandled
// rejection in chrome JS.
pending.catch(() => {});
let timer;
const timedOut = new Promise(resolve => {
timer = setTimeout(() => resolve(kEventTimedOut), timeoutMs);
});
try {
const result = await Promise.race([pending, timedOut]);
return result === kEventTimedOut ? null : result;
} finally {
clearTimeout(timer);
}
}
async ensureEvents(eventNames, predicate) {
if (!Array.isArray(eventNames))
throw new Error('ERROR: ensureEvents expects an array of event names as its first argument');
+29 -1
View File
@@ -11,6 +11,23 @@ const {AppConstants} = ChromeUtils.importESModule("resource://gre/modules/AppCon
// scripts), so the screencast tick has to import them explicitly.
const {setTimeout, clearTimeout} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
// Last-resort bound on how long one callback may occupy the process-global
// activation chain below. The chain must always advance: a callback that never
// returns wedges every later input event in every tab, permanently.
//
// The per-ack deadline in MouseDispatch.js covers the await that has actually
// caused all four shipped deadlocks, but it is one of several unbounded waits
// reachable from a single slot -- apz-repaints-flushed, TabSwitchDone below,
// the drag path's juggler-drag-finalized and dragover waits, and the
// cross-process dispatchDragEvent sends all have the same shape. None of them
// has failed yet. Bounding only the wait that has already bitten us is the
// posture that produced those four fixes, so bound the slot itself too.
//
// Sized as a backstop, not a tuning knob: with a 5s ack deadline a legitimate
// worst-case input slot approaches 10s, so this must sit well clear of that.
const kActivationSlotBudgetMs = 30000;
const kSlotExpired = Symbol('activation-slot-expired');
const Cr = Components.results;
const helper = new Helper();
@@ -512,7 +529,18 @@ export class PageTarget {
const notificationsPopup = muteNotificationsPopup ? this._linkedBrowser?.ownerDocument.getElementById('notification-popup') : null;
notificationsPopup?.style.setProperty('pointer-events', 'none');
try {
await callback();
let timer;
const expired = new Promise(resolve => {
timer = setTimeout(() => resolve(kSlotExpired), kActivationSlotBudgetMs);
});
try {
if (await Promise.race([callback(), expired]) === kSlotExpired) {
dump(`[juggler] WARN activation-chain slot exceeded ` +
`${kActivationSlotBudgetMs}ms; advancing the chain without it\n`);
}
} finally {
clearTimeout(timer);
}
} finally {
notificationsPopup?.style.removeProperty('pointer-events');
}
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
/* 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/. */
"use strict";
/**
* The one place in the parent process that dispatches synthesized mouse input.
*
* It exists because the alternative did not work. Between 2026-04 and 2026-09,
* four separate deadlocks shipped -- exact-edge coordinates (#225), humanized
* trajectory points that bypassed the endpoint's guard (#677/#225), a
* zero-displacement move, and the top-edge row (#751/#752) -- each fixed by
* adding one more coordinate guard at one more call site. The guards were
* correct; the arithmetic behind them was copied per call site, so every new
* dispatch site was a fresh chance to get it wrong, and every mistake cost the
* whole browser process.
*
* THE INVARIANT
* A synthesized input event whose ack we await must reach the content
* renderer -- and when it does not, we must stop waiting.
*
* Nothing else in juggler may call jugglerSendMouseEvent / sendWheelEvent or do
* browser-relative coordinate arithmetic; scripts/check-input-dispatch.py fails
* the build if it does. See docs/input-dispatch.md.
*/
const {setTimeout} = ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
/**
* How long to wait for a juggler-mouse-event-hit-renderer ack before giving up.
*
* Measured on v152.0.4-beta.30, headless Linux, over 1000+ dispatches:
*
* content main thread p50 p99 max
* idle 0ms 1ms 12ms
* busy (8ms burned/event) 8ms 9ms 12ms
* blocked (3s sync script) 0ms 1ms 2849ms
*
* Typical latency is three orders of magnitude below this. The deadline is not
* sized by the typical case though: the ack is delivered FROM the content main
* thread, so it inherits any block on it, and block length is page-controlled
* and unbounded. Pages that block for seconds are the normal case on the
* targets Camoufox exists to handle. Sizing this near the p99 would silently
* drop real input on merely-slow pages -- a correctness bug wearing the exact
* costume of the deadlock it replaces. Waiting too long costs a few seconds
* once and then recovers; waiting too little costs input loss that nobody can
* diagnose. So: well above the slowest legitimate ack, not near the typical one.
*/
export const kAckDeadlineMs = 5000;
/** Delay between humanized trajectory points, preserving the original cadence. */
export const kTrajectoryStepMs = 10;
function warnUndelivered(eventType, x, y, box, deadlineMs) {
dump(
`[juggler] WARN ${eventType} at (${x}, ${y}) was not delivered to the ` +
`renderer after ${deadlineMs}ms; dropping it (browser rect ` +
`${box.width}x${box.height} at +${box.left}+${box.top})\n`
);
}
export class MouseDispatch {
/**
* @param {Window} win chrome window owning the browser element.
* @param {DOMRect} boundingBox the browser element's rect, already measured.
* @param {object} eventArgs button / clickCount / modifiers / buttons.
*/
constructor(win, boundingBox, {button = 0, clickCount = 0, modifiers = 0, buttons = 0} = {}) {
this._win = win;
this._box = boundingBox;
this._args = {button, clickCount, modifiers, buttons};
// The first whole pixel inside the browser element on each axis.
//
// The element's origin is not pixel-aligned: the chrome above the content is
// a fractional number of CSS pixels tall, and how many depends on the spoofed
// OS (measured: windows 51.4, macos 53.1, linux 56.5). A relative coordinate
// of 0 therefore dispatches at absolute y == boundingBox.top exactly -- the
// content area's first, only partly covered row. The widget rounds that to a
// whole device row before hit-testing it, and wherever round(top) < top the
// rounded row still belongs to chrome, so the event fires as an exit event
// rather than eMouseMove and no ack is ever produced (#751, #752).
//
// Snapping onto ceil() keeps the point inside content pixel 0 while landing
// clear of the boundary. It is a sub-pixel shift and only ever affects the
// first row/column; boundingBox.left is normally a whole 0 and unaffected.
this._originX = Math.ceil(boundingBox.left);
this._originY = Math.ceil(boundingBox.top);
// The far edge has the same problem, and the bounds check below cannot see
// it either. The element's height is fractional too -- measured, it is
// consistently 0.5 CSS px less than the innerHeight the page reports, so
// the page's last row is only half covered. A point in it dispatches at an
// absolute coordinate that rounds onto the row *past* the content, and is
// dropped exactly like the top-edge case. Deterministic: with the box at
// 1920x977.5 +0+56.5, relative y == 977 (innerHeight - 1, well inside the
// viewport as far as the page is concerned) dispatches at 1033.5, rounds to
// 1034, and the content ends at 1034.
//
// So clamp to the last whole pixel fully inside the element as well. Found
// by tests/patches/mouse-boundary-sweep.py, not by a report -- it predates
// this module and deadlocks a stock build.
this._limitX = Math.ceil(boundingBox.left + boundingBox.width) - 1;
this._limitY = Math.ceil(boundingBox.top + boundingBox.height) - 1;
}
static forBrowser(win, linkedBrowser, eventArgs) {
return new MouseDispatch(win, linkedBrowser.getBoundingClientRect(), eventArgs);
}
get boundingBox() {
return this._box;
}
/**
* Is this relative point inside the content viewport at all?
*
* The far edges are exclusive: a point at exactly x == width or y == height
* lies on the opposite boundary row and fires as an exit event, so it must be
* treated as out-of-viewport rather than dispatched (#225). The near edges are
* inclusive -- 0 is a legitimate coordinate a caller may ask for, and the
* constructor's snap is what makes it safe to dispatch.
*/
isInViewport(x, y) {
return x >= 0 && y >= 0 && x < this._box.width && y < this._box.height;
}
/** Relative point -> absolute, snapped clear of both of the element's edges. */
toAbsolute(x, y) {
return {
x: Math.min(Math.max(x + this._box.left, this._originX), this._limitX),
y: Math.min(Math.max(y + this._box.top, this._originY), this._limitY),
};
}
_sendAbsolute(eventType, absX, absY) {
return this._win.windowUtils.jugglerSendMouseEvent(
eventType,
absX,
absY,
this._args.button,
this._args.clickCount,
this._args.modifiers,
false /* aIgnoreRootScrollFrame */,
0.0 /* pressure */,
0 /* inputSource */,
true /* isDOMEventSynthesized */,
false /* isWidgetEventSynthesized */,
this._args.buttons,
this._win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */,
false /* disablePointerEvent */
);
}
/**
* Dispatch one event and wait for the renderer to ack it, under a deadline.
*
* Returns the ack event object, or null if none arrived in time -- in which
* case the event is dropped and a warning is logged. Never rejects and never
* waits forever: input dispatch is serialized on activateAndRun()'s
* process-global chain, so an unbounded wait here wedges every later input
* event in the process, in every tab, for the life of the browser.
*/
async sendAcked(watcher, eventType, x, y, deadlineMs = kAckDeadlineMs) {
const {x: absX, y: absY} = this.toAbsolute(x, y);
// This dispatches to the renderer synchronously.
const jugglerEventId = this._sendAbsolute(eventType, absX, absY);
const ack = await watcher.ensureEventWithin(
eventType, deadlineMs, eventObject => eventObject.jugglerEventId === jugglerEventId);
if (!ack)
warnUndelivered(eventType, x, y, this._box, deadlineMs);
return ack;
}
/**
* Dispatch the intermediate points of a humanized trajectory.
*
* Points outside the viewport are skipped. Bounding each ack individually is
* not enough to bound the work: a curve is ~110 points dispatched inside a
* SINGLE activation-chain slot, so a curve riding a coordinate that cannot be
* delivered would spend 110 x the deadline there. Rather than a wall-clock
* budget -- which would false-fire on exactly the slow pages the deadline
* exists to tolerate -- the first undelivered point abandons the rest of the
* curve. Intermediate points are humanization garnish: if one did not reach
* the renderer the rest of that curve almost certainly will not either, and
* dropping them costs realism, not correctness. The caller still dispatches
* the real destination afterwards.
*
* @returns {boolean} true if the whole curve was delivered.
*/
async sendTrajectoryAcked(watcher, eventType, points, stepDelayMs = kTrajectoryStepMs) {
for (const [x, y] of points) {
if (!this.isInViewport(x, y))
continue;
if (!await this.sendAcked(watcher, eventType, x, y))
return false;
await new Promise(resolve => setTimeout(resolve, stepDelayMs));
}
return true;
}
/**
* Park the cursor off web content so hover effects clear.
*
* Deliberately dispatched at the chrome window's own origin rather than a
* content coordinate, and deliberately unacked: it never enters the renderer,
* so there is no ack to wait for.
*/
parkOffContent() {
this._sendAbsolute('mousemove', 0, 0);
}
/** Wheel events take the same conversion; they are not acked. */
sendWheel(x, y, {deltaX, deltaY, deltaZ, deltaMode, lineOrPageDeltaX, lineOrPageDeltaY}) {
const {x: absX, y: absY} = this.toAbsolute(x, y);
this._win.windowUtils.sendWheelEvent(
absX,
absY,
deltaX,
deltaY,
deltaZ,
deltaMode,
this._args.modifiers,
lineOrPageDeltaX,
lineOrPageDeltaY,
0 /* options */);
}
}
+1
View File
@@ -8,6 +8,7 @@ juggler.jar:
content/components/Juggler.js (components/Juggler.js)
content/Helper.js (Helper.js)
content/input/MouseDispatch.js (input/MouseDispatch.js)
content/NetworkObserver.js (NetworkObserver.js)
content/ChannelEventSink.sys.mjs (ChannelEventSink.sys.mjs)
content/TargetRegistry.js (TargetRegistry.js)
+22 -69
View File
@@ -9,6 +9,7 @@ const {NetUtil} = ChromeUtils.importESModule('resource://gre/modules/NetUtil.sys
const {NetworkObserver, PageNetwork} = ChromeUtils.importESModule('chrome://juggler/content/NetworkObserver.js');
const {PageTarget} = ChromeUtils.importESModule('chrome://juggler/content/TargetRegistry.js');
const {setTimeout} = ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs');
const {MouseDispatch} = ChromeUtils.importESModule('chrome://juggler/content/input/MouseDispatch.js');
const Cc = Components.classes;
const Ci = Components.interfaces;
@@ -517,65 +518,36 @@ export class PageHandler {
async ['Page.dispatchMouseEvent']({type, x, y, button, clickCount, modifiers, buttons}) {
const win = this._pageTarget._window;
const eventArgs = {button, clickCount, modifiers, buttons};
const sendEvents = async (types) => {
// 1. Scroll element to the desired location first; the coordinates are relative to the element.
this._pageTarget._linkedBrowser.scrollRectIntoViewIfNeeded(x, y, 0, 0);
// 2. Get element's bounding box in the browser after the scroll is completed.
const boundingBox = this._pageTarget._linkedBrowser.getBoundingClientRect();
// MouseDispatch owns every conversion from these relative coordinates to
// absolute ones, and every wait for a renderer ack.
const dispatch = MouseDispatch.forBrowser(win, this._pageTarget._linkedBrowser, eventArgs);
// 3. Make sure compositor is flushed after scrolling.
if (win.windowUtils.flushApzRepaints())
await helper.awaitTopic('apz-repaints-flushed');
const watcher = new EventWatcher(this._pageEventSink, types, this._pendingEventWatchers);
// Dispatch a single synthesized mouse event to the renderer and return a
// promise that resolves once the renderer acks it.
const sendOne = (eventType, eventX, eventY) => {
// This dispatches to the renderer synchronously.
const jugglerEventId = win.windowUtils.jugglerSendMouseEvent(
eventType,
eventX + boundingBox.left,
eventY + boundingBox.top,
button,
clickCount,
modifiers,
false /* aIgnoreRootScrollFrame */,
0.0 /* pressure */,
0 /* inputSource */,
true /* isDOMEventSynthesized */,
false /* isWidgetEventSynthesized */,
buttons,
win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */,
false /* disablePointerEvent */
);
return watcher.ensureEvent(eventType, eventObject => eventObject.jugglerEventId === jugglerEventId);
};
const promises = [];
for (const type of types) {
for (const eventType of types) {
// Camoufox: when humanize is enabled, expand a direct mousemove into a
// human-like trajectory of intermediate mousemoves generated in C++
// (ChromeUtils.camouGetMouseTrajectory / MouseTrajectories.hpp).
if (type === 'mousemove' && ChromeUtils.camouGetBool('humanize', false)) {
if (eventType === 'mousemove' && ChromeUtils.camouGetBool('humanize', false)) {
const trajectory = ChromeUtils.camouGetMouseTrajectory(this._lastTrackedPos.x, this._lastTrackedPos.y, x, y);
// Dispatch intermediate points sequentially with a short delay. The
// first/last pairs are skipped: the last pair is the exact
// The first and last pairs are skipped: the last pair is the exact
// destination, which is dispatched explicitly below.
for (let i = 2; i < trajectory.length - 2; i += 2) {
const currentX = trajectory[i];
const currentY = trajectory[i + 1];
// Skip movement that is out of bounds. Must match the endpoint guard
// below (>=, not >): a point at exactly x==width or y==height fires as
// an exit event instead of eMouseMove, so the hit-renderer ack never
// arrives and every later input event hangs behind it forever.
if (currentX < 0 || currentY < 0 || currentX >= boundingBox.width || currentY >= boundingBox.height)
continue;
await sendOne('mousemove', currentX, currentY);
await new Promise(resolve => setTimeout(resolve, 10));
}
const points = [];
for (let i = 2; i < trajectory.length - 2; i += 2)
points.push([trajectory[i], trajectory[i + 1]]);
await dispatch.sendTrajectoryAcked(watcher, 'mousemove', points);
// Always finish exactly on the requested destination.
promises.push(sendOne('mousemove', x, y));
promises.push(dispatch.sendAcked(watcher, 'mousemove', x, y));
} else {
promises.push(sendOne(type, x, y));
promises.push(dispatch.sendAcked(watcher, eventType, x, y));
}
}
await Promise.all(promises);
@@ -588,32 +560,15 @@ export class PageHandler {
await this._pageTarget.activateAndRun(async () => {
this._pageTarget.ensureContextMenuClosed();
// If someone asks us to dispatch mouse event outside of viewport, then we normally would drop it.
const boundingBox = this._pageTarget._linkedBrowser.getBoundingClientRect();
// Treat exact-edge coordinates as out-of-viewport: a mousemove at x==width or y==height fires as an exit event instead of eMouseMove, so the hit-renderer signal never arrives and every later input event hangs behind it forever.
if (x < 0 || y < 0 || x >= boundingBox.width || y >= boundingBox.height) {
const dispatch = MouseDispatch.forBrowser(win, this._pageTarget._linkedBrowser, eventArgs);
if (!dispatch.isInViewport(x, y)) {
if (type !== 'mousemove')
return;
// A special hack: if someone tries to do `mousemove` outside of
// viewport coordinates, then move the mouse off from the Web Content.
// This way we can eliminate all the hover effects.
// NOTE: since this won't go inside the renderer, there's no need to wait for ACK.
win.windowUtils.jugglerSendMouseEvent(
'mousemove',
0 /* x */,
0 /* y */,
button,
clickCount,
modifiers,
false /* aIgnoreRootScrollFrame */,
0.0 /* pressure */,
0 /* inputSource */,
true /* isDOMEventSynthesized */,
false /* isWidgetEventSynthesized */,
buttons,
win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */,
false /* disablePointerEvent */
);
dispatch.parkOffContent();
return;
}
@@ -700,24 +655,22 @@ export class PageHandler {
// 1. Scroll element to the desired location first; the coordinates are relative to the element.
this._pageTarget._linkedBrowser.scrollRectIntoViewIfNeeded(x, y, 0, 0);
// 2. Get element's bounding box in the browser after the scroll is completed.
const boundingBox = this._pageTarget._linkedBrowser.getBoundingClientRect();
const win = this._pageTarget._window;
const dispatch = MouseDispatch.forBrowser(win, this._pageTarget._linkedBrowser, {modifiers});
// 3. Make sure compositor is flushed after scrolling.
if (win.windowUtils.flushApzRepaints())
await helper.awaitTopic('apz-repaints-flushed');
win.windowUtils.sendWheelEvent(
x + boundingBox.left,
y + boundingBox.top,
// Same conversion as a mouse event: a wheel at relative y == 0 would
// otherwise land on the chrome/content boundary and scroll the tab strip.
dispatch.sendWheel(x, y, {
deltaX,
deltaY,
deltaZ,
deltaMode,
modifiers,
lineOrPageDeltaX,
lineOrPageDeltaY,
0 /* options */);
});
}, { muteNotificationsPopup: true });
}
+73
View File
@@ -0,0 +1,73 @@
# Synthesized input dispatch
Every synthesized mouse and wheel event in the parent process goes through
`additions/juggler/input/MouseDispatch.js`. `scripts/check-input-dispatch.py`
fails the build if anything else dispatches input or does browser-relative
coordinate arithmetic, and it runs on every pull request.
## The invariant
> A synthesized input event whose ack we await must reach the content
> renderer — and when it does not, we must stop waiting.
## Why it is worth a module and a lint
Four deadlocks shipped between 2026-04 and 2026-09, all the same failure:
| Date | Commit | Trigger |
|---|---|---|
| 2026-06-04 | `9270618` | `x == width` / `y == height` — the far edges |
| 2026-07-18 | `541ffca` | trajectory points, which bypassed the endpoint's guard (#225, #677) |
| 2026-07-24 | `16e5a13` | a zero-displacement move |
| 2026-09-03 | `014cc65` | `y == 0` — the near edge (#751, #752) |
Each was fixed by adding one more coordinate guard at one more call site. That
does not converge, for two reasons.
**The trigger set is not enumerable.** Whether relative `y == 0` reaches the
renderer is decided by `Math.round(boundingBox.top) < boundingBox.top` — a
rounding accident in the fractional height of browser chrome, which varies with
the *spoofed OS*: windows `51.4``51` deadlocks, macos `53.1``53`
deadlocks, linux `56.5``57` is fine. No review catches that, and no
hand-written list of coordinates contains it.
**Every miss costs the whole process.** `activateAndRun()`
(`TargetRegistry.js`) serializes input on a promise chain shared by every tab in
the process. It swallows errors to keep the chain running, but it cannot swallow
a callback that never returns. One unbounded `await` for an ack that will never
arrive wedges every later input event, in every tab, permanently — at 0% CPU,
with nothing in flight and no diagnostic.
`#677` is why review is not enough: restoring the humanize trajectory meant
writing a bounds check, and the one written was a copy of the pre-`#225` form,
reintroducing a fixed deadlock one day before it was re-fixed.
## How it is enforced
**One chokepoint.** `MouseDispatch` owns the relative→absolute conversion (with
the boundary snap), the in-viewport predicate, and the ack wait. Callers pass
relative coordinates and never see a bounding box.
**Bounded waits.** `sendAcked()` waits at most `kAckDeadlineMs` (5s) and then
drops the event with a warning naming the type, coordinate and browser rect. The
deadline is sized above the slowest *legitimate* ack, not near the typical one:
acks are p99 1ms on an idle page, but they are delivered from the content main
thread and inherit any block on it — a 3s synchronous script delayed one by
2849ms. `sendTrajectoryAcked()` abandons the rest of a curve after the first
undelivered point, so ~110 bounded waits cannot add up to an unbounded slot.
`activateAndRun()` carries a 30s backstop for the other unbounded waits
reachable from the same slot (`apz-repaints-flushed`, `TabSwitchDone`, the drag
path's waits), none of which has failed yet.
**The static check.** `scripts/check-input-dispatch.py`, wired into
`.github/workflows/lint.yml`. Two exemptions, both content-process:
`PageAgent.js` (drag events, already content-relative, no ack) and
`FrameTree.js` (the ack *producer*).
**Boundary coverage.** `tests/patches/mouse-boundary-sweep.py` sweeps the whole
viewport ring across every spoofed OS with humanize on and off, asserting each
point is acked *and observed by the page*. Hand-picked coordinate lists are what
let each of the four bugs through: `humanize-edge-deadlock.py` probed only the
far edges, and `humanize-mouse-trajectory.py` pins `os="linux"` — the one
fingerprint immune to `#751`. `tests/patches/input-ack-backstop.py` covers the
bounded wait itself.
+166 -36
View File
@@ -1,5 +1,42 @@
Camoufox: restrict font family resolution to the spoofed "fonts" allowlist.
The allowlist is applied when a family is looked up, NOT by handing the list to
Firefox's `font.system.whitelist` pref (which is what this patch used to do).
That pref drives gfxPlatformFontList::ApplyWhitelist(), which physically
removes every non-listed family from the process-wide font list -- and, when
the shared font list is enabled (the default on all three platforms), from the
read-only list the parent process builds and shares with every content
process.
The parent process is also the process that paints the browser chrome, so
pruning the list there strips the UI of the fonts it needs. On Windows that
leaves the titlebar buttons drawing tofu boxes instead of their Segoe Fluent
Icons / Segoe MDL2 Assets glyphs (U+E921 / U+E922 / U+E8BB) and the toolbar
falling back to serif: the icon fonts are not in any of the generated masks,
and "Segoe UI" only survives when the spoofed OS is Windows. Linux hosts were
insulated by the bundled FONTCONFIG_FILE, which is why this only shows up on
Windows and macOS builds.
Filtering at lookup time instead keys off FontVisibilityProvider::IsChrome(),
so chrome documents (browser UI, devtools, about: pages) keep the host's real
font list while web content still only ever resolves families from the mask.
The three content-reachable paths are covered:
- FindAndAddFamiliesLocked -- font-family / CSS generic resolution, which is
what document.fonts.check() and text-metric
probing go through.
- GlobalFontFallback -- per-character system fallback; also keeps the
cmap path forced, as the whitelist did, so the
platform fallback APIs can't pick a family
behind our back.
- gfxUserFontSet local() -- upstream already refuses local() sources while
a whitelist is active; keep that behaviour for
content under the mask, so a page can't probe
for hidden families via src: local().
Bug: daijro/camoufox#695
diff --git a/gfx/thebes/gfxPlatformFontList.cpp b/gfx/thebes/gfxPlatformFontList.cpp
index 6bdcd2a57c..41d46bf26c 100644
--- a/gfx/thebes/gfxPlatformFontList.cpp
+++ b/gfx/thebes/gfxPlatformFontList.cpp
@@ -13,6 +13,7 @@
@@ -10,28 +47,131 @@ index 6bdcd2a57c..41d46bf26c 100644
#include "FontVisibilityProvider.h"
@@ -313,6 +314,16 @@ gfxPlatformFontList::gfxPlatformFontList(bool aNeedFullnamePostscriptNames)
@@ -890,6 +891,29 @@
ToLowerCase(aKeyName);
}
mFontPrefs = MakeUnique<FontPrefs>();
+ // Hijack the kFontSystemWhitelistPref pref
+ if (std::vector<std::string> fontValues = MaskConfig::GetStringList("fonts");
+ !fontValues.empty()) {
+ std::string fontValuesJoined =
+ std::accumulate(fontValues.begin(), fontValues.end(), std::string(),
+ [](const std::string& acc, const std::string& s) {
+ return acc.empty() ? s : acc + "," + s;
+ });
+ Preferences::SetCString(kFontSystemWhitelistPref, fontValuesJoined.data());
+/* static */
+bool gfxPlatformFontList::MaskedFontListAppliesTo(
+ FontVisibilityProvider* aFontVisibilityProvider) {
+ if (!MaskConfig::HasFontAllowlist()) {
+ return false;
+ }
gfxFontUtils::GetPrefsFontList(kFontSystemWhitelistPref, mEnabledFontsList);
mFontFamilyWhitelistActive = !mEnabledFontsList.IsEmpty();
+ // Chrome documents are not inspectable by the page and they need the host's
+ // real UI fonts -- on Windows the titlebar buttons are Segoe Fluent Icons
+ // glyphs and the toolbar is Segoe UI -- so they are exempt. A null provider
+ // is an internal lookup with no document behind it, which upstream likewise
+ // treats as fully privileged.
+ return aFontVisibilityProvider && !aFontVisibilityProvider->IsChrome();
+}
+
+/* static */
+bool gfxPlatformFontList::MaskedFontListBlocks(
+ FontVisibilityProvider* aFontVisibilityProvider,
+ const nsACString& aLowercaseFamily) {
+ return MaskedFontListAppliesTo(aFontVisibilityProvider) &&
+ !MaskConfig::IsFontAllowed(std::string_view(
+ aLowercaseFamily.BeginReading(), aLowercaseFamily.Length()));
+}
+
// Used if a stylo thread wants to trigger InitOtherFamilyNames in the main
// process: we can't do IPC from the stylo thread so we post this to the main
// thread instead.
@@ -1418,7 +1442,11 @@
uint32_t aNextCh, Script aRunScript, FontPresentation aPresentation,
const gfxFontStyle* aMatchStyle, uint32_t& aCmapCount,
FontFamily& aMatchedFamily) {
- bool useCmaps = IsFontFamilyWhitelistActive() ||
+ // Camoufox: under the spoofed font list, force the cmap path for the same
+ // reason a whitelist does -- the platform fallback APIs choose a family for
+ // us, which would bypass the filtering in the loops below.
+ const bool maskedFontList = MaskedFontListAppliesTo(aFontVisibilityProvider);
+ bool useCmaps = IsFontFamilyWhitelistActive() || maskedFontList ||
gfxPlatform::GetPlatform()->UseCmapsDuringSystemFallback();
FontVisibility level = aFontVisibilityProvider
? aFontVisibilityProvider->GetFontVisibility()
@@ -1468,6 +1496,11 @@
if (!IsVisibleToCSS(family, level)) {
continue;
}
+ if (maskedFontList &&
+ MaskedFontListBlocks(aFontVisibilityProvider,
+ family.Key().AsString(SharedFontList()))) {
+ continue;
+ }
if (!family.IsFullyInitialized() &&
StaticPrefs::gfx_font_rendering_fallback_async() &&
!XRE_IsParentProcess()) {
@@ -1494,6 +1527,13 @@
if (!IsVisibleToCSS(*family, level)) {
continue;
}
+ if (maskedFontList) {
+ nsAutoCString familyKey;
+ GenerateFontListKey(family->Name(), familyKey);
+ if (MaskedFontListBlocks(aFontVisibilityProvider, familyKey)) {
+ continue;
+ }
+ }
// evaluate all fonts in this family for a match
family->FindFontForChar(&data);
if (data.mMatchDistance == 0.0) {
@@ -1754,6 +1794,11 @@
aFontVisibilityProvider ? aFontVisibilityProvider->GetFontVisibility()
: FontVisibility::User;
+ // Camoufox: content only ever resolves families from the spoofed font list.
+ if (MaskedFontListBlocks(aFontVisibilityProvider, key)) {
+ return false;
+ }
+
// If this font lookup is the result of resolving a CSS generic (not a direct
// font-family request by the page), and RFP settings allow generics to be
// unrestricted, bump the effective visibility level applied here so as to
diff --git a/gfx/thebes/gfxPlatformFontList.h b/gfx/thebes/gfxPlatformFontList.h
--- a/gfx/thebes/gfxPlatformFontList.h
+++ b/gfx/thebes/gfxPlatformFontList.h
@@ -652,6 +652,19 @@
return mFontFamilyWhitelistActive;
};
+ // Camoufox: the spoofed "fonts" allowlist is applied when a family is looked
+ // up rather than by pruning the process-wide (and cross-process shared) font
+ // list, so that the browser's own chrome UI keeps the host's real UI fonts.
+ // See daijro/camoufox#695.
+ //
+ // MaskedFontListAppliesTo: is aFontVisibilityProvider subject to the mask?
+ // MaskedFontListBlocks: ...and is aLowercaseFamily absent from it?
+ static bool MaskedFontListAppliesTo(
+ FontVisibilityProvider* aFontVisibilityProvider);
+ static bool MaskedFontListBlocks(
+ FontVisibilityProvider* aFontVisibilityProvider,
+ const nsACString& aLowercaseFamily);
+
static void FontWhitelistPrefChanged(const char* aPref, void* aClosure);
bool AddWithLegacyFamilyName(const nsACString& aLegacyName,
diff --git a/gfx/thebes/gfxUserFontSet.cpp b/gfx/thebes/gfxUserFontSet.cpp
--- a/gfx/thebes/gfxUserFontSet.cpp
+++ b/gfx/thebes/gfxUserFontSet.cpp
@@ -461,8 +461,12 @@
gfxPlatformFontList* pfl = gfxPlatformFontList::PlatformFontList();
pfl->AddUserFontSet(fontSet);
// Don't look up local fonts if the font whitelist is being used.
+ // Camoufox: nor for content under the spoofed font list -- src: local()
+ // would otherwise let a page probe for families the mask hides.
gfxFontEntry* fe = nullptr;
- if (!pfl->IsFontFamilyWhitelistActive()) {
+ if (!pfl->IsFontFamilyWhitelistActive() &&
+ !gfxPlatformFontList::MaskedFontListAppliesTo(
+ fontSet->GetFontVisibilityProvider())) {
fe = gfxPlatform::GetPlatform()->LookupLocalFont(
fontSet->GetFontVisibilityProvider(), currSrc.mLocalName, Weight(),
Stretch(), SlantStyle());
diff --git a/gfx/thebes/moz.build b/gfx/thebes/moz.build
index 2bb893d84f..0088f6cab7 100644
--- a/gfx/thebes/moz.build
+++ b/gfx/thebes/moz.build
@@ -299,3 +299,6 @@ DEFINES["GRAPHITE2_STATIC"] = True
@@ -299,3 +299,6 @@
COMPILE_FLAGS["WARNINGS_CXXFLAGS"] += ["-Werror=switch"]
include("/tools/fuzzing/libfuzzer-config.mozbuild")
@@ -39,10 +179,9 @@ index 2bb893d84f..0088f6cab7 100644
+# DOM Mask
+LOCAL_INCLUDES += ["/camoucfg"]
diff --git a/layout/style/FontFace.cpp b/layout/style/FontFace.cpp
index 56e579eb33..5a679f75ba 100644
--- a/layout/style/FontFace.cpp
+++ b/layout/style/FontFace.cpp
@@ -239,7 +239,16 @@ void FontFace::SetSizeAdjust(const nsACString& aValue, ErrorResult& aRv) {
@@ -239,7 +239,16 @@
mImpl->SetSizeAdjust(aValue, aRv);
}
@@ -60,7 +199,7 @@ index 56e579eb33..5a679f75ba 100644
Promise* FontFace::Load(ErrorResult& aRv) {
EnsurePromise();
@@ -249,7 +258,17 @@ Promise* FontFace::Load(ErrorResult& aRv) {
@@ -249,7 +258,17 @@
return nullptr;
}
@@ -80,10 +219,9 @@ index 56e579eb33..5a679f75ba 100644
return mLoaded;
}
diff --git a/layout/style/FontFaceImpl.cpp b/layout/style/FontFaceImpl.cpp
index f13782ae6d..abb8d83fb3 100644
--- a/layout/style/FontFaceImpl.cpp
+++ b/layout/style/FontFaceImpl.cpp
@@ -354,21 +354,15 @@ void FontFaceImpl::DoLoad() {
@@ -354,21 +354,15 @@
void FontFaceImpl::SetStatus(FontFaceLoadStatus aStatus) {
gfxFontUtils::AssertSafeThreadOrServoFontMetricsLocked();
@@ -112,7 +250,6 @@ index f13782ae6d..abb8d83fb3 100644
mFontFaceSet->OnFontFaceStatusChanged(this);
}
diff --git a/layout/style/FontFaceImpl.h b/layout/style/FontFaceImpl.h
index c94388212e..2826682e7b 100644
--- a/layout/style/FontFaceImpl.h
+++ b/layout/style/FontFaceImpl.h
@@ -8,6 +8,7 @@
@@ -123,32 +260,25 @@ index c94388212e..2826682e7b 100644
#include "nsTHashSet.h"
class gfxFontFaceBufferSource;
@@ -27,6 +28,20 @@ class UTF8StringOrArrayBufferOrArrayBufferView;
@@ -27,6 +28,14 @@
namespace mozilla::dom {
+// Helper function to check if a font is in the allowed list
+inline bool IsFontAllowed(const nsACString& aFontName) {
+ if (std::vector<std::string> maskValues =
+ MaskConfig::GetStringListLower("fonts");
+ !maskValues.empty()) {
+ std::string fontName(aFontName.BeginReading(), aFontName.EndReading());
+ std::transform(fontName.begin(), fontName.end(), fontName.begin(),
+ ::tolower);
+ return std::find(maskValues.begin(), maskValues.end(), fontName) !=
+ maskValues.end();
+ }
+ return true;
+ std::string fontName(aFontName.BeginReading(), aFontName.Length());
+ std::transform(fontName.begin(), fontName.end(), fontName.begin(),
+ [](unsigned char c) { return std::tolower(c); });
+ return MaskConfig::IsFontAllowed(fontName);
+}
+
class FontFaceImpl final {
NS_INLINE_DECL_THREADSAFE_REFCOUNTING(FontFaceImpl)
diff --git a/layout/style/moz.build b/layout/style/moz.build
index b3886e491f..9236483cec 100644
--- a/layout/style/moz.build
+++ b/layout/style/moz.build
@@ -370,3 +370,6 @@ if CONFIG["COMPILE_ENVIRONMENT"]:
@@ -370,3 +370,6 @@
"ServoStyleConsts.h",
inputs=["/servo/ports/geckolib", "/servo/components/style"],
)
+68 -197
View File
@@ -1,8 +1,8 @@
Camoufox: spoof CSS2 system-font keyword resolution to match navigator.platform.
Sibling to font-system-ui.patch. That patch covers the `system-ui` generic
via gfxPlatformFontList::GetSystemUIFontFamilies(). It does NOT cover the
older CSS2 system-font shorthand keywords:
Sibling to system-ui-font-spoofing.patch. That patch covers the `system-ui`
generic via gfxPlatformFontList::GetSystemUIFontFamilies(). It does NOT cover
the older CSS2 system-font shorthand keywords:
font: caption | icon | menu | message-box | small-caption | status-bar
@@ -18,19 +18,17 @@ keywords:
// GeckoFonts["Sans"] === GeckoFonts["sans-serif"] === "Linux"
// -> emits "Sans:Linux" / "sans-serif:Linux"
Firefox routes CSS system-font resolution through TWO different paths
depending on whether RFP's FontVisibilityRestrictGenerics target is on:
Firefox routes CSS system-font resolution through nsLayoutUtils::
ComputeSystemFont(), which either asks nsLayoutUtils' own
GetSpoofedSystemFontForRFP() (when RFP's FontVisibilityRestrictGenerics target
is on) or the platform LookAndFeel (otherwise). FontVisibilityRestrictGenerics
is not in Firefox's default-enabled set (RFPTargetsDefault.inc) and Camoufox
never turns it on, so upstream always took the LookAndFeel branch and the real
host system font leaked through getComputedStyle().fontFamily.
1. RFP off (or target off): nsLookAndFeel::PerThemeData::GetFont()
returns the GTK system font (e.g. "Cantarell").
2. RFP on (Camoufox default): nsLayoutUtils::GetSpoofedSystemFontForRFP()
returns "sans-serif" (Linux build) or "-apple-system" (Mac build) etc.
Camoufox runs RFP on by default, so CreepJS sees path 2 — which on the
Linux build hardcodes "sans-serif" and entirely bypasses the
LookAndFeel::GetFont call site.
This patch hooks BOTH paths to honor navigator.platform:
This patch makes ComputeSystemFont() take the spoof branch whenever
navigator.platform is spoofed, and teaches GetSpoofedSystemFontForRFP() to
answer for the spoofed OS:
- "-apple-system" when navigator.platform starts with "Mac"
(matches CreepJS GeckoFonts entry "-apple-system" -> "Mac")
@@ -38,90 +36,29 @@ This patch hooks BOTH paths to honor navigator.platform:
(matches CreepJS GeckoFonts entry "Segoe UI" -> "Windows")
- (falls through) otherwise (Linux / iPhone / unspoofed)
For path 1 we patch widget/gtk/nsLookAndFeel.cpp's PerThemeData::GetFont.
For path 2 we patch layout/base/nsLayoutUtils.cpp's
GetSpoofedSystemFontForRFP (file-static) helper. Both files need
LOCAL_INCLUDES += ["/camoucfg"] in their respective moz.build to expose
MaskConfig.hpp.
Chrome documents are excluded from the spoof branch and keep the host's real
system font. They are not reachable by the page, and handing the browser's own
UI a family name from another OS ("Segoe UI" on Linux, "-apple-system" on
Windows) leaves the toolbar, menus and titlebar falling back to the default
serif font -- see daijro/camoufox#695.
Both hooks classify navigator.platform once via a function-static and
keep the result for the lifetime of the process. CreepJS calls the CSS
resolver six times in tight succession (one per CSS2 keyword) and uses
probe duration as a probabilistic headless signal, so we don't want to
re-parse CAMOU_CONFIG on every call. The config is set at process start
and never changes, so a single classification per process is safe.
Because ComputeSystemFont() is the single entry point and now covers every
content document unconditionally, the LookAndFeel-level hooks this patch used
to carry (widget/gtk/nsLookAndFeel.cpp::PerThemeData::GetFont and
gfxMacPlatformFontList::LookupSystemFont) can never fire for content: they are
reached only when the spoof branch is *not* taken, which is exactly when
navigator.platform is unspoofed (where they were no-ops) or when the document
is chrome (where they broke the UI). They are dropped here.
Bug: daijro/camoufox#598 (sibling of font-system-ui.patch)
The classification is cached in a function-static. CreepJS calls the CSS
resolver six times in tight succession (one per CSS2 keyword) and uses probe
duration as a probabilistic headless signal, so we don't want to re-parse
CAMOU_CONFIG on every call. The config is set at process start and never
changes, so a single classification per process is safe.
--- a/widget/gtk/moz.build
+++ b/widget/gtk/moz.build
@@ -155,6 +155,7 @@
FINAL_LIBRARY = "xul"
Bug: daijro/camoufox#598, daijro/camoufox#695
LOCAL_INCLUDES += [
+ "/camoucfg",
"/layout/base",
"/layout/forms",
"/layout/generic",
--- a/widget/gtk/nsLookAndFeel.cpp
+++ b/widget/gtk/nsLookAndFeel.cpp
@@ -34,6 +34,7 @@
#include "mozilla/glean/WidgetGtkMetrics.h"
#include "mozilla/ScopeExit.h"
#include "mozilla/WidgetUtilsGtk.h"
+#include "MaskConfig.hpp"
#include "ScreenHelperGTK.h"
#include "ScrollbarDrawing.h"
@@ -1302,6 +1303,48 @@
bool nsLookAndFeel::PerThemeData::GetFont(FontID aID, nsString& aFontName,
gfxFontStyle& aFontStyle,
float aTextScaleFactor) const {
+ // Camoufox: spoof the CSS2 system-font keyword family resolution
+ // (caption / icon / menu / message-box / small-caption / status-bar)
+ // based on navigator.platform so we don't leak the host's GTK system
+ // font through getComputedStyle().fontFamily. Sibling to
+ // font-system-ui.patch which handles the `system-ui` generic.
+ //
+ // The platform classification is cached in a function-static. CreepJS's
+ // headless detector calls getComputedStyle().fontFamily after setting
+ // `font: caption !important` six times in tight succession, and uses
+ // probe duration as a probabilistic headless signal. navigator.platform
+ // is set from CAMOU_CONFIG at process start and never changes, so a
+ // single lookup per process is safe and removes the per-call overhead.
+ enum class SpoofedOS { Other, Mac, Win };
+ static const SpoofedOS sSpoofedOS = []() {
+ auto platform = MaskConfig::GetString("navigator.platform");
+ if (!platform) return SpoofedOS::Other;
+ const std::string& p = platform.value();
+ auto starts_with = [&](const char* prefix) {
+ size_t n = std::strlen(prefix);
+ return p.size() >= n &&
+ std::equal(prefix, prefix + n, p.begin(),
+ [](char a, char b) {
+ return std::tolower(static_cast<unsigned char>(a)) ==
+ std::tolower(static_cast<unsigned char>(b));
+ });
+ };
+ if (starts_with("mac")) return SpoofedOS::Mac;
+ if (starts_with("win")) return SpoofedOS::Win;
+ return SpoofedOS::Other;
+ }();
+ if (sSpoofedOS == SpoofedOS::Mac) {
+ aFontName.AssignLiteral(u"-apple-system");
+ aFontStyle = mDefaultFontStyle;
+ return true;
+ }
+ if (sSpoofedOS == SpoofedOS::Win) {
+ aFontName.AssignLiteral(u"Segoe UI");
+ aFontStyle = mDefaultFontStyle;
+ return true;
+ }
+ // Linux / iPhone / unknown: fall through to upstream behavior.
+
switch (aID) {
case FontID::Menu: // css2
case FontID::MozPullDownMenu: // css3
diff --git a/layout/base/moz.build b/layout/base/moz.build
--- a/layout/base/moz.build
+++ b/layout/base/moz.build
@@ -146,6 +146,7 @@
@@ -132,38 +69,40 @@ Bug: daijro/camoufox#598 (sibling of font-system-ui.patch)
"/docshell/base",
"/dom/base",
"/dom/html",
diff --git a/layout/base/nsLayoutUtils.cpp b/layout/base/nsLayoutUtils.cpp
--- a/layout/base/nsLayoutUtils.cpp
+++ b/layout/base/nsLayoutUtils.cpp
@@ -6,6 +6,7 @@
@@ -4,6 +4,7 @@
#include "nsLayoutUtils.h"
+#include "MaskConfig.hpp"
#include <algorithm>
#include <limits>
@@ -9623,6 +9654,54 @@
@@ -9718,6 +9719,55 @@
static void GetSpoofedSystemFontForRFP(LookAndFeel::FontID aFontID,
gfxFontStyle& aStyle, nsAString& aName) {
+ // Camoufox: when navigator.platform is spoofed, return the family-name
+ // string the spoofed OS would. Firefox's CSS resolver routes the CSS2
+ // system-font keywords (caption / icon / menu / message-box /
+ // small-caption / status-bar) through this function whenever RFP's
+ // FontVisibilityRestrictGenerics target is on (default for Camoufox),
+ // bypassing nsLookAndFeel::PerThemeData::GetFont entirely. CreepJS's
+ // headless detector reads `getComputedStyle().fontFamily` after setting
+ // `font: caption !important` and emits "<family>:Linux" / "<family>:Mac"
+ // / "<family>:Windows" attributions. Without this hook the Linux build
+ // emits `sans-serif` (which fontconfig resolves to "Sans") for every
+ // spoofed OS, leaking Linux. Sibling to the nsLookAndFeel::GetFont hook
+ // in widget/gtk/nsLookAndFeel.cpp.
+ // small-caption / status-bar) through this function. CreepJS's headless
+ // detector reads `getComputedStyle().fontFamily` after setting
+ // `font: caption !important` and emits "<family>:Linux" / "<family>:Mac" /
+ // "<family>:Windows" attributions. Without this hook the Linux build emits
+ // `sans-serif` (which fontconfig resolves to "Sans") for every spoofed OS,
+ // leaking Linux. Sibling to the system-ui hook in
+ // gfxPlatformFontList::GetSystemUIFontFamilies().
+ //
+ // ComputeSystemFont() below only routes *content* documents here, so this
+ // never changes what the browser's own chrome UI resolves to.
+ //
+ // The platform classification is cached in a function-static because
+ // CreepJS calls this six times in tight succession (one per CSS2
+ // keyword) and uses probe duration as a probabilistic headless signal.
+ // navigator.platform is set from CAMOU_CONFIG at process start and
+ // never changes, so a single lookup per process is safe.
+ // CreepJS calls this six times in tight succession (one per CSS2 keyword)
+ // and uses probe duration as a probabilistic headless signal.
+ // navigator.platform is set from CAMOU_CONFIG at process start and never
+ // changes, so a single lookup per process is safe.
+ enum class SpoofedOS { Other, Mac, Win };
+ static const SpoofedOS sSpoofedOS = []() {
+ auto platform = MaskConfig::GetString("navigator.platform");
@@ -192,101 +131,33 @@ Bug: daijro/camoufox#598 (sibling of font-system-ui.patch)
+ aStyle.size = 12;
+ return;
+ }
+ // Linux / iPhone / unknown: fall through to upstream behavior.
+ // Linux / iPhone / unspoofed: fall through to upstream behavior.
+
#if defined(XP_MACOSX) || defined(MOZ_WIDGET_UIKIT)
aName = u"-apple-system"_ns;
// Values taken from a macOS 10.15 system.
@@ -9729,7 +9729,17 @@ void nsLayoutUtils::ComputeSystemFont(nsFont* aSystemFont,
@@ -9772,8 +9822,22 @@
const Document* aDocument) {
gfxFontStyle fontStyle;
nsAutoString systemFontName;
- if (aDocument->ShouldResistFingerprinting(
- RFPTarget::FontVisibilityRestrictGenerics)) {
+ // Camoufox: take the spoof path whenever navigator.platform is spoofed, not
+ // only when the FontVisibilityRestrictGenerics RFP target is active. That
+ // target is NOT in Firefox's default-enabled set (RFPTargetsDefault.inc) and
+ // Camoufox never turns it on, so the original guard left the spoof dead: the
+ // real GTK system font ("sans-serif" on Linux) leaked through
+ // real system font ("sans-serif" on Linux) leaked through
+ // getComputedStyle().fontFamily for the CSS2 system-font keywords, exposing
+ // the host OS (CreepJS "platform hints: sans-serif:Linux"). When no platform
+ // is spoofed, GetSpoofedSystemFontForRFP falls through to the native value,
+ // so this is a no-op for unspoofed contexts.
+ if (MaskConfig::GetString("navigator.platform").has_value() ||
+ aDocument->ShouldResistFingerprinting(
RFPTarget::FontVisibilityRestrictGenerics)) {
+ // the host OS (CreepJS "platform hints: sans-serif:Linux").
+ //
+ // Chrome documents are excluded. They are not reachable by the page, and
+ // handing the browser's own UI a family name from another OS ("Segoe UI" on
+ // Linux, "-apple-system" on Windows) leaves the toolbar and menus falling
+ // back to the default serif font -- see daijro/camoufox#695.
+ if (aDocument && !aDocument->IsInChromeDocShell() &&
+ (MaskConfig::GetString("navigator.platform").has_value() ||
+ aDocument->ShouldResistFingerprinting(
+ RFPTarget::FontVisibilityRestrictGenerics))) {
GetSpoofedSystemFontForRFP(aFontID, fontStyle, systemFontName);
} else if (!LookAndFeel::GetFont(aFontID, systemFontName, fontStyle)) {
diff --git a/gfx/thebes/gfxMacPlatformFontList.mm b/gfx/thebes/gfxMacPlatformFontList.mm
--- a/gfx/thebes/gfxMacPlatformFontList.mm
+++ b/gfx/thebes/gfxMacPlatformFontList.mm
@@ -17,6 +17,7 @@
#include "SharedFontList-impl.h"
#include "harfbuzz/hb.h"
+#include "MaskConfig.hpp"
#include "AppleUtils.h"
#include "MainThreadUtils.h"
@@ -394,7 +395,48 @@ void gfxMacPlatformFontList::LookupSystemFont(LookAndFeel::FontID aSystemFontID,
}
NS_ASSERTION(font, "system font not set");
- aSystemFontName.AssignASCII("-apple-system");
+ // Camoufox: spoof the CSS2 system-font keyword family resolution
+ // (caption / icon / menu / message-box / small-caption / status-bar)
+ // based on navigator.platform. This is the cocoa sibling of the GTK
+ // hook in widget/gtk/nsLookAndFeel.cpp::PerThemeData::GetFont.
+ //
+ // Without this hook the Mac build always emits "-apple-system" here,
+ // which CreepJS's GeckoFonts map attributes to "Mac" — leaking the
+ // host OS through `getComputedStyle().fontFamily` even when
+ // navigator.platform is spoofed to Win32.
+ //
+ // The Camoufox-runs-RFP-by-default premise that the nsLayoutUtils.cpp
+ // hunk relied on is no longer true: privacy.resistFingerprinting is
+ // off and FontVisibilityRestrictGenerics is not in the default RFP
+ // target set, so ComputeSystemFont takes the LookAndFeel::GetFont
+ // branch on every call. On Mac that lands here.
+ //
+ // The platform classification is cached in a function-static for the
+ // same reason as the sibling hooks: CreepJS uses probe duration as a
+ // probabilistic headless signal.
+ enum class SpoofedOS { Other, Mac, Win };
+ static const SpoofedOS sSpoofedOS = []() {
+ auto platform = MaskConfig::GetString("navigator.platform");
+ if (!platform) return SpoofedOS::Other;
+ const std::string& p = platform.value();
+ auto starts_with = [&](const char* prefix) {
+ size_t n = std::strlen(prefix);
+ return p.size() >= n &&
+ std::equal(prefix, prefix + n, p.begin(),
+ [](char a, char b) {
+ return std::tolower(static_cast<unsigned char>(a)) ==
+ std::tolower(static_cast<unsigned char>(b));
+ });
+ };
+ if (starts_with("mac")) return SpoofedOS::Mac;
+ if (starts_with("win")) return SpoofedOS::Win;
+ return SpoofedOS::Other;
+ }();
+ if (sSpoofedOS == SpoofedOS::Win) {
+ aSystemFontName.AssignASCII("Segoe UI");
+ } else {
+ aSystemFontName.AssignASCII("-apple-system");
+ }
NSFontSymbolicTraits traits = font.fontDescriptor.symbolicTraits;
aFontStyle.style = (traits & NSFontItalicTrait) ? FontSlantStyle::ITALIC
--- a/gfx/thebes/moz.build
+++ b/gfx/thebes/moz.build
@@ -302,3 +302,9 @@
# DOM Mask
LOCAL_INCLUDES += ["/camoucfg"]
+
+# Objective-C++ compilation in this directory keeps exceptions enabled for
+# ObjC @try/@catch even when C++ has -fno-exceptions, which makes
+# nlohmann/json (transitively included via MaskConfig.hpp) emit `throw`
+# expressions that don't compile. Force the JSON_THROW=std::abort path.
+DEFINES["JSON_NOEXCEPTION"] = True
return;
+36 -10
View File
@@ -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<PointerCapabilities>(intValue);
+ return PointerCapabilities::Fine | PointerCapabilities::Hover;
+ return capabilities;
+#endif
}
PointerCapabilities Gecko_MediaFeatures_PrimaryPointerCapabilities(
const Document* aDocument) {
return GetPointerCapabilities(aDocument,
LookAndFeel::IntID::PrimaryPointerCapabilities);
}
@@ -1,23 +0,0 @@
From: Jenkins <jenkins@magrathea>
Date: Fri, 3 Mar 2023 09:03:01 +0100
Subject: Disable Onboarding Messages
---
browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs
index ceded6b755..90aa3abe36 100644
--- a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs
+++ b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs
@@ -1213,11 +1213,9 @@ const BASE_MESSAGES = () => [
];
// Eventually, move Feature Callout messages to their own provider
-const ONBOARDING_MESSAGES = () =>
- BASE_MESSAGES().concat(FeatureCalloutMessages.getMessages());
+const ONBOARDING_MESSAGES = () => ([]);
export const OnboardingMessageProvider = {
async getExtraAttributes() {
@@ -1,13 +0,0 @@
diff --git a/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs
index 575205158bb8..e0c76c682a7d 100644
--- a/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs
+++ b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs
@@ -454,9 +454,8 @@ export class UrlbarProviderInterventions extends UrlbarProvider {
]),
});
for (let [id, phrases] of Object.entries(DOCUMENTS)) {
- queryScorer.addDocument({ id, phrases });
+// queryScorer.addDocument({ id, phrases });
}
return queryScorer;
});
+74
View File
@@ -0,0 +1,74 @@
diff --git a/dom/media/mp4/MP4Decoder.cpp b/dom/media/mp4/MP4Decoder.cpp
index abcdef0001..abcdef0002 100644
--- a/dom/media/mp4/MP4Decoder.cpp
+++ b/dom/media/mp4/MP4Decoder.cpp
@@ -10,6 +10,7 @@
#include "MediaContainerType.h"
#include "PDMFactory.h"
#include "PlatformDecoderModule.h"
+#include "MaskConfig.hpp"
#include "VPXDecoder.h"
#include "VideoUtils.h"
#include "mozilla/StaticPrefs_media.h"
@@ -133,6 +134,14 @@ bool MP4Decoder::IsSupportedType(const MediaContainerType& aType,
return false;
}
+ // Camoufox: When media:spoof_codecs is enabled, always report MP4 as
+ // supported. This prevents canPlayType()/isTypeSupported() from leaking
+ // which system codec libraries (FFmpeg, VideoToolbox, GStreamer) are
+ // installed. Actual playback may fail if no decoder is present.
+ if (MaskConfig::GetBool("media:spoof_codecs").value_or(false)) {
+ return true;
+ }
+
if (!tracks.IsEmpty()) {
// Look for exact match as we know used codecs.
RefPtr<PDMFactory> platform = new PDMFactory();
diff --git a/dom/media/mp4/moz.build b/dom/media/mp4/moz.build
index abcdef0003..abcdef0004 100644
--- a/dom/media/mp4/moz.build
+++ b/dom/media/mp4/moz.build
@@ -32,3 +32,6 @@ CXXFLAGS += [
# Add libFuzzer configuration directives
include("/tools/fuzzing/libfuzzer-config.mozbuild")
+
+# DOM Mask
+LOCAL_INCLUDES += ["/camoucfg"]
diff --git a/dom/media/webm/MatroskaDecoder.cpp b/dom/media/webm/MatroskaDecoder.cpp
index abcdef0005..abcdef0006 100644
--- a/dom/media/webm/MatroskaDecoder.cpp
+++ b/dom/media/webm/MatroskaDecoder.cpp
@@ -12,6 +12,7 @@
#include "MediaContainerType.h"
#include "PDMFactory.h"
#include "PlatformDecoderModule.h"
+#include "MaskConfig.hpp"
#include "VideoUtils.h"
#include "mozilla/StaticPrefs_media.h"
#include "nsMimeTypes.h"
@@ -130,6 +131,12 @@ bool MatroskaDecoder::IsSupportedType(const MediaContainerType& aContainerType,
if (!tracks.IsEmpty()) {
// Look for exact match as we know the codecs used.
+
+ // Camoufox: bypass system decoder check when media:spoof_codecs is enabled.
+ if (MaskConfig::GetBool("media:spoof_codecs").value_or(false)) {
+ return true;
+ }
+
RefPtr<PDMFactory> platform = new PDMFactory();
for (const auto& track : tracks) {
if (!track ||
diff --git a/dom/media/webm/moz.build b/dom/media/webm/moz.build
index abcdef0007..abcdef0008 100644
--- a/dom/media/webm/moz.build
+++ b/dom/media/webm/moz.build
@@ -29,3 +29,6 @@ FINAL_LIBRARY = "xul"
# Add libFuzzer configuration directives
include("/tools/fuzzing/libfuzzer-config.mozbuild")
+
+# DOM Mask
+LOCAL_INCLUDES += ["/camoucfg"]
+53 -10
View File
@@ -12,28 +12,71 @@ index e5bda9f62c..f8988a4430 100644
queryContext.searchString.length > UrlbarUtils.MAX_TEXT_LENGTH ||
lazy.UrlUtils.REGEXP_LIKE_PROTOCOL.test(queryContext.searchString) ||
diff --git a/toolkit/components/search/SearchEngineSelector.sys.mjs b/toolkit/components/search/SearchEngineSelector.sys.mjs
index 85c9d83282..15cfeac054 100644
--- a/toolkit/components/search/SearchEngineSelector.sys.mjs
+++ b/toolkit/components/search/SearchEngineSelector.sys.mjs
@@ -331,6 +331,21 @@ export class SearchEngineSelector {
@@ -325,12 +325,65 @@ export class SearchEngineSelector {
* Internal boolean to indicate if this is the first time check or not.
* @returns {Promise<object[]>}
* An array of objects in the database, or an empty array if none
* could be obtained.
*/
async #getConfiguration(firstTime = true) {
+ if (true) {
+ // Camoufox ships no search engines. This stub stands in for the Remote
+ // Settings fetch, which is dead anyway: camoufox.cfg sets
+ // services.settings.server to "", so nothing is ever fetched.
+ //
+ // The shape matters. This used to return a search-config **v1** record
+ // (`appliesTo`/`webExtension`, no `recordType`), but the selector is the
+ // Rust SearchEngineSelector, which deserializes v2:
+ //
+ // #[serde(tag = "recordType", rename_all = "camelCase")]
+ // enum JSONSearchConfigurationRecords { ... }
+ //
+ // `recordType` is the enum's tag, so a record without it aborts the whole
+ // document with `missing field \`recordType\``. setSearchConfig() threw on
+ // every launch, #init() died, and the browser ended up with no search
+ // service at all -- which also takes out the urlbar's heuristic result,
+ // so history and autofill never render (daijro/camoufox#737).
+ //
+ // Returning [] is not an option either: getEngineConfiguration() rejects
+ // an empty array with "Failed to get engine data from Remote Settings".
+ // So the configuration has to carry one inert engine. Its search URL
+ // points at the loopback address, so the "no engines" stance holds even
+ // if something does try to submit a search -- the request cannot leave
+ // the machine. settings/distribution/policies.json separately defines a
+ // "None" engine and makes it the default; this simply agrees with it.
+ return [
+ {
+ "appliesTo": [{
+ "default": "yes",
+ "included": {
+ "everywhere": true
+ },
+ "webExtension": {
+ "id": "none@mozilla.org"
+ "recordType": "engine",
+ "identifier": "none",
+ "base": {
+ "name": "None",
+ "classification": "unknown",
+ "urls": {
+ "search": {
+ "base": "http://127.0.0.1/",
+ "method": "GET",
+ "searchTermParamName": "q"
+ }
+ }
+ }],
+ },
+ "variants": [{ "environment": { "allRegionsAndLocales": true } }]
+ },
+ {
+ "recordType": "defaultEngines",
+ "globalDefault": "none",
+ "specificDefaults": []
+ },
+ {
+ "recordType": "engineOrders",
+ "orders": []
+ }
+ ];
+ }
let result = [];
let failed = false;
try {
result = await this.#remoteConfig.get({
order: "id",
});
+24
View File
@@ -0,0 +1,24 @@
# Patch Dependencies
Quick reference for which patches depend on shared infrastructure.
## camoucfg (MaskConfig)
Most patches read config via `MaskConfig::GetBool()`, `MaskConfig::GetString()`, etc. from `/camoucfg`. Any patch that adds `LOCAL_INCLUDES += ["/camoucfg"]` to a `moz.build` file depends on `config.patch` being applied first (which provides the `camoucfg` directory).
### Patches using MaskConfig
| Patch | Config keys | What it does |
|-------|-------------|--------------|
| `media-codec-spoofing.patch` | `media:spoof_codecs` | Bypasses `PDMFactory::Supports()` checks in `MP4Decoder` and `MatroskaDecoder` so `canPlayType()`/`isTypeSupported()` don't leak system codec libraries |
| `navigator-spoofing.patch` | Various `navigator:*` keys | Per-context navigator property spoofing |
| `geolocation-spoofing.patch` | `geo:*` keys | Geolocation coordinate spoofing |
| `locale-spoofing.patch` | `locale:*` keys | Language/locale spoofing |
## RoverfoxStorageManager
Per-context patches that use cross-process storage depend on `cross-process-storage.patch`.
## Playwright
All patches should be applied after `0-playwright.patch` and `1-leak-fixes.patch`.
+29 -11
View File
@@ -1,20 +1,38 @@
Camoufox: spoof the `system-ui` CSS generic to match the spoofed OS.
Sibling to font-system-fonts-css2.patch, which covers the older CSS2
system-font shorthand keywords (caption / icon / menu / message-box /
small-caption / status-bar) via nsLayoutUtils::ComputeSystemFont().
Chrome documents are excluded. The browser's own UI is not reachable by the
page, and resolving its `system-ui` to a family from another OS ("Helvetica"
or "Segoe UI" on a host that has neither) leaves the toolbar and menus falling
back to the default serif font.
Bug: daijro/camoufox#695 (chrome exemption), daijro/camoufox#599 (spoof)
diff --git a/gfx/thebes/gfxPlatformFontList.cpp b/gfx/thebes/gfxPlatformFontList.cpp
index d82edfc767..2e05cedcb3 100644
--- a/gfx/thebes/gfxPlatformFontList.cpp
+++ b/gfx/thebes/gfxPlatformFontList.cpp
@@ -2246,6 +2246,18 @@ void gfxPlatformFontList::MaybeRemoveCmap(gfxCharacterMap* aCharMap,
@@ -2245,6 +2245,24 @@
static void GetSystemUIFontFamilies(
FontVisibilityProvider* aFontVisibilityProvider,
[[maybe_unused]] nsAtom* aLangGroup, nsTArray<nsCString>& aFamilies) {
+ // Camoufox: spoof system-ui to match the spoofed OS
+ if (auto platform = MaskConfig::GetString("navigator.platform"); platform) {
+ if (*platform == "MacIntel") {
+ *aFamilies.AppendElement() = "Helvetica"_ns;
+ return;
+ }
+ if (*platform == "Win32") {
+ *aFamilies.AppendElement() = "Segoe UI"_ns;
+ return;
+ // Camoufox: spoof system-ui to match the spoofed OS. Chrome documents are
+ // excluded: they are not reachable by the page, and pointing the browser's
+ // own UI at a family from another OS leaves it falling back to the default
+ // serif font (daijro/camoufox#695). A null provider means there is no
+ // document behind the lookup, where spoofing is the safe default.
+ if (!(aFontVisibilityProvider && aFontVisibilityProvider->IsChrome())) {
+ if (auto platform = MaskConfig::GetString("navigator.platform"); platform) {
+ if (*platform == "MacIntel") {
+ *aFamilies.AppendElement() = "Helvetica"_ns;
+ return;
+ }
+ if (*platform == "Win32") {
+ *aFamilies.AppendElement() = "Segoe UI"_ns;
+ return;
+ }
+ }
+ }
+
@@ -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<uint32_t>& 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"]
+8 -3
View File
@@ -1,4 +1,5 @@
import os
import shutil
from enum import Enum
from multiprocessing import Lock
from typing import List, Optional
@@ -74,14 +75,16 @@ def maybe_download_addons(
# Get the addon path
addon_path = get_addon_path(addon.name)
# Check if the addon is already extracted
if os.path.exists(addon_path):
# Check if the addon is already extracted. A bare directory is not
# enough: a failed download leaves an empty dir behind, so require the
# manifest that confirm_paths() looks for.
if os.path.exists(os.path.join(addon_path, 'manifest.json')):
# Add the existing addon path to addons_list
if addons_list is not None:
addons_list.append(addon_path)
continue
# Addon doesn't exist, create directory and download
# Addon isn't extracted, create directory and download
try:
os.makedirs(addon_path, exist_ok=True)
download_and_extract(addon.value, addon_path, addon.name)
@@ -89,4 +92,6 @@ def maybe_download_addons(
if addons_list is not None:
addons_list.append(addon_path)
except Exception as e:
# Drop the partial directory so the next run re-downloads.
shutil.rmtree(addon_path, ignore_errors=True)
print(f"Failed to download and extract {addon.name}: {e}")
+8
View File
@@ -14,6 +14,14 @@ class MissingRelease(Exception):
...
class CorruptedDownload(Exception):
"""
Raised when a downloaded asset does not match its expected sha256 digest.
"""
...
class UnsupportedArchitecture(Exception):
"""
Raised when the architecture is not supported.
+45
View File
@@ -738,6 +738,33 @@ def get_random_preset(
return choice(candidates) # nosec
# Tokens that name the machine rather than the platform: Firefox leaves every one
# of them out of appVersion.
_APP_VERSION_DROPPED = ('Win64', 'x64', 'Mobile', 'Tablet')
def _app_version_from_user_agent(user_agent: str) -> Optional[str]:
"""The appVersion Firefox reports for a browser sending this user agent.
"5.0 (<OS tokens>)": the parenthesised part of the UA without the
architecture, the Gecko revision, or the Windows build number.
"""
block = re.match(r'Mozilla/5\.0 \(([^)]*)\)', user_agent or '')
if not block:
return None
kept = []
for token in (part.strip() for part in block.group(1).split(';')):
if (
token.startswith('rv:')
or token in _APP_VERSION_DROPPED
or token.startswith('Linux ')
or token.startswith('Intel Mac OS X')
):
continue
kept.append('Windows' if token.startswith('Windows') else token)
return f"5.0 ({'; '.join(kept)})" if kept else None
def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any]:
"""
Convert a real fingerprint preset to CAMOU_CONFIG format.
@@ -767,6 +794,24 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any
config['navigator.oscpu'] = 'Windows NT 10.0; Win64; x64'
elif 'Linux' in plat or 'linux' in plat:
config['navigator.oscpu'] = 'Linux x86_64'
if nav.get('appVersion'):
config['navigator.appVersion'] = nav['appVersion']
elif config.get('navigator.userAgent'):
# Left unset, appVersion falls through to the *host's* value and then
# contradicts the userAgent and platform set above: a Linux preset on a
# macOS host reported "5.0 (Macintosh)" beside platform "Linux x86_64",
# which any page can read in two properties.
#
# Firefox builds it from the same OS tokens as the userAgent, minus the
# architecture and rv, with Windows collapsed to its family name. Deriving
# it from the UA rather than from the platform keeps the distro token that
# 20 of the bundled Linux presets carry ("X11; Ubuntu"), which a platform
# lookup would flatten to "X11" — a mismatch of the same kind, if a
# smaller one. Checked against 800 browserforge fingerprints: exact every
# time.
derived = _app_version_from_user_agent(config['navigator.userAgent'])
if derived:
config['navigator.appVersion'] = derived
if 'maxTouchPoints' in nav:
config['navigator.maxTouchPoints'] = nav['maxTouchPoints']
+6 -2
View File
@@ -32,7 +32,7 @@ from ..multiversion import (
save_repo_cache,
set_active,
)
from ..pkgman import RepoConfig, unzip, webdl
from ..pkgman import RepoConfig, unzip, verify_sha256, webdl
# Workers
@@ -74,8 +74,12 @@ class DownloadWorker(Worker):
with tempfile.NamedTemporaryFile() as f:
webdl(self.version.url, buffer=f, bar=False, progress_callback=self._progress)
self.status.emit("Extracting...")
self.status.emit("Verifying...")
self.progress.emit(-1)
verify_sha256(
f, self.version.sha256, desc=f"Camoufox v{self.version.version.full_string}"
)
self.status.emit("Extracting...")
unzip(f, str(path), bar=False)
(path / 'version.json').write_bytes(orjson.dumps(self.version.to_metadata()))
+6 -17
View File
@@ -1,12 +1,9 @@
import re
import warnings
from contextlib import contextmanager
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, Optional, Tuple
import requests
from urllib3.exceptions import InsecureRequestWarning
from .exceptions import InvalidIP, InvalidProxy
@@ -78,13 +75,6 @@ def validate_ip(ip: str) -> None:
raise InvalidIP(f"Invalid IP address: {ip}")
@contextmanager
def _suppress_insecure_warning():
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=InsecureRequestWarning)
yield
@lru_cache(maxsize=None)
def public_ip(proxy: Optional[str] = None) -> str:
"""
@@ -104,13 +94,12 @@ def public_ip(proxy: Optional[str] = None) -> str:
end_exception = None
for url in URLS:
try:
with _suppress_insecure_warning():
resp = requests.get( # nosec
url,
proxies=Proxy.as_requests_proxy(proxy) if proxy else None,
timeout=5,
verify=False,
)
resp = requests.get(
url,
proxies=Proxy.as_requests_proxy(proxy) if proxy else None,
timeout=5,
verify=True,
)
resp.raise_for_status()
ip = resp.text.strip()
validate_ip(ip)
+9 -1
View File
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
import orjson
import rich_click as click
from .pkgman import INSTALL_DIR, OS_NAME, Version, rprint, unzip
from .pkgman import INSTALL_DIR, OS_NAME, Version, rprint, unzip, verify_sha256
BROWSERS_DIR: Path = INSTALL_DIR / "browsers"
CONFIG_FILE: Path = INSTALL_DIR / "config.json"
@@ -420,6 +420,14 @@ def install_versioned(fetcher, replace: bool = False) -> bool:
with tempfile.NamedTemporaryFile() as temp_file:
fetcher.download_file(temp_file, fetcher.url)
expected_sha = (
fetcher._selected_version.sha256
if fetcher._selected_version
else getattr(fetcher, "installed_sha256", None)
)
verify_sha256(temp_file, expected_sha, desc=f"Camoufox v{fetcher.verstr}")
rprint(f'Extracting Camoufox: {install_path}')
unzip(temp_file, str(install_path))
+29
View File
@@ -1,3 +1,4 @@
import hashlib
import os
import platform
import re
@@ -31,6 +32,7 @@ from yaml import CLoader, load
from .__version__ import CONSTRAINTS
from .exceptions import (
CamoufoxNotInstalled,
CorruptedDownload,
MissingRelease,
ProfileDirectoryError,
UnsupportedArchitecture,
@@ -950,6 +952,33 @@ def webdl(
return buffer
def verify_sha256(buffer: DownloadBuffer, expected: Optional[str], desc: str = "asset") -> None:
"""
Check a downloaded buffer against its expected sha256 digest.
Raises CorruptedDownload on mismatch. Skips silently when no digest is
known, so installs from sources that publish no digest still work.
"""
if not expected:
rprint(f"Warning: no sha256 published for {desc}; skipping verification.", fg="yellow")
return
buffer.seek(0)
digest = hashlib.sha256()
for block in iter(lambda: buffer.read(1024 * 1024), b""):
digest.update(block)
buffer.seek(0)
actual = digest.hexdigest()
if actual != expected.lower():
raise CorruptedDownload(
f"Checksum mismatch for {desc}.\n"
f" expected sha256: {expected.lower()}\n"
f" actual sha256: {actual}\n"
"The download was corrupted or tampered with. Installation aborted."
)
def unzip(
zip_file: DownloadBuffer,
extract_path: str,
+10 -2
View File
@@ -330,7 +330,7 @@ def get_screen_cons(headless: Optional[bool] = None) -> Optional[Screen]:
Bounds are CSS pixels, the unit Firefox lays its windows out in -- see
camoufox.display for why that differs from the monitor's physical size.
"""
if headless is False:
if headless is True:
return None # Skip if headless
display = largest_display()
if display is None:
@@ -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)
@@ -786,7 +791,10 @@ def launch_options(
# Bound the geometry to the real display. BrowserForge only honours this when
# its pool has a match, so it is re-applied after generation as well.
screen_cons = screen or get_screen_cons(headless or has_display(env))
# `headless` and "is there a display to probe" are separate questions: passing
# `headless or has_display(env)` made a headful run on a real display look like a
# headless one to get_screen_cons(), which then skipped the bound entirely.
screen_cons = screen or (get_screen_cons(headless) if has_display(env) else None)
if not _used_preset and fingerprint is None:
# Default: BrowserForge synthetic generation (infinite unique fingerprints)
+9 -1
View File
@@ -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.
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.
+95
View File
@@ -0,0 +1,95 @@
"""
Tests for camoufox.addons default-addon download/caching.
Regression guard for #308: a partial/failed first download leaves an empty
addon directory behind. The old "already downloaded" check was a bare
os.path.exists(dir), so that empty dir was trusted forever and every later
launch raised InvalidAddonPath ("manifest.json is missing"), unrecoverable
short of manually deleting the cache.
Run with:
cd pythonlib && python -m pytest tests/test_addons.py -v
"""
import os
import sys
import pytest
# Make `import camoufox` resolve to the in-tree pythonlib without an install.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from camoufox import addons as addons_mod # noqa: E402
from camoufox.addons import DefaultAddons, maybe_download_addons # noqa: E402
UBO = DefaultAddons.UBO.name
@pytest.fixture
def addons_dir(tmp_path, monkeypatch):
# Point the addon store at a throwaway dir so no real cache is touched.
root = tmp_path / "addons"
monkeypatch.setattr(addons_mod, "get_addon_path", lambda name: str(root / name))
return root
def _write_manifest(url, extract_path, name):
os.makedirs(extract_path, exist_ok=True)
with open(os.path.join(extract_path, "manifest.json"), "w") as f:
f.write("{}")
def test_partial_dir_is_redownloaded(addons_dir, monkeypatch):
# Leftover empty dir from a failed first download.
partial = addons_dir / UBO
partial.mkdir(parents=True)
assert not (partial / "manifest.json").exists()
calls = []
def fake(url, extract_path, name):
calls.append(name)
_write_manifest(url, extract_path, name)
monkeypatch.setattr(addons_mod, "download_and_extract", fake)
out = []
maybe_download_addons([DefaultAddons.UBO], out)
# An empty dir must trigger a re-download, not be trusted.
assert calls == [UBO]
assert (partial / "manifest.json").exists()
assert out == [str(partial)]
def test_extracted_addon_is_not_redownloaded(addons_dir, monkeypatch):
path = addons_dir / UBO
path.mkdir(parents=True)
(path / "manifest.json").write_text("{}")
def boom(*a, **k):
raise AssertionError("must not re-download an already-extracted addon")
monkeypatch.setattr(addons_mod, "download_and_extract", boom)
out = []
maybe_download_addons([DefaultAddons.UBO], out)
assert out == [str(path)]
def test_failed_download_removes_partial_dir(addons_dir, monkeypatch):
path = addons_dir / UBO
def fail(url, extract_path, name):
os.makedirs(extract_path, exist_ok=True) # partial write, then die
raise RuntimeError("network died mid-download")
monkeypatch.setattr(addons_mod, "download_and_extract", fail)
out = []
maybe_download_addons([DefaultAddons.UBO], out)
# The partial dir must be gone so the next run re-downloads instead of
# trusting an addon that has no manifest.json.
assert not path.exists()
assert out == []
+107
View File
@@ -0,0 +1,107 @@
"""
Guard: every config key the browser reads must be declared in the schema.
Regression guard for the `media:spoof_codecs` gap in PR #562. The C++ side read
the key via MaskConfig::GetBool("media:spoof_codecs"), but nothing ever added it
to settings/properties.json, and validate_config() drops any key it does not
recognise -- printing "Skipping unknown patch media:spoof_codecs" and moving on.
The documented usage,
AsyncCamoufox(config={"media:spoof_codecs": True})
therefore did nothing at all: the key never reached the browser, so the feature
could not be switched on through the supported path.
This is the read-but-undeclared direction of a mistake the project has made
before in the other direction -- canvas:seed (#721) and navigator.maxTouchPoints
(#696) were both declared in the schema while nothing consumed them. A patch and
a schema entry are two halves of one change; this test fails the build when only
one half lands.
Run with:
cd pythonlib && python -m pytest tests/test_config_schema.py -v
"""
import json
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
PROPERTIES = REPO / "settings" / "properties.json"
# MaskConfig::GetBool("k") / GetString("k") / GetUint32("k") / HasKey("k") ...
MASKCONFIG_READ = re.compile(r'MaskConfig::(?:Get|Has)\w*\(\s*"([^"]+)"')
# Keys read through a variable or built at runtime rather than a string literal.
# Add here (with a reason) only when the read genuinely cannot name its key.
ALLOWED_UNDECLARED: set = set()
def _sources():
for pattern in ("patches/**/*.patch", "additions/**/*"):
for path in REPO.glob(pattern):
if path.is_file():
yield path
def _declared_keys() -> set:
return {entry["property"] for entry in json.loads(PROPERTIES.read_text())}
def _keys_read() -> dict:
"""Map config key -> sorted list of files that read it."""
found: dict = {}
for path in _sources():
try:
text = path.read_text(errors="ignore")
except OSError:
continue
for match in MASKCONFIG_READ.finditer(text):
found.setdefault(match.group(1), set()).add(
str(path.relative_to(REPO))
)
return {k: sorted(v) for k, v in found.items()}
def test_properties_json_is_wellformed():
entries = json.loads(PROPERTIES.read_text())
assert entries, "settings/properties.json is empty"
for entry in entries:
assert "property" in entry and "type" in entry, f"malformed entry: {entry}"
def test_every_key_the_browser_reads_is_declared():
declared = _declared_keys()
read = _keys_read()
assert read, "found no MaskConfig reads -- the scanner regexp has gone stale"
undeclared = {
key: files
for key, files in read.items()
if key not in declared and key not in ALLOWED_UNDECLARED
}
if undeclared:
lines = [
"config keys are read by the browser but missing from "
"settings/properties.json,",
"so validate_config() silently drops them and the feature cannot be "
"enabled through the Python API:",
"",
]
for key, files in sorted(undeclared.items()):
lines.append(f" {key}")
for f in files:
lines.append(f" read in {f}")
pytest.fail("\n".join(lines))
@pytest.mark.parametrize("key", ["media:spoof_codecs"])
def test_known_previously_missing_keys_stay_declared(key):
"""Pin the specific keys this guard was written for, so a schema edit that
drops one fails loudly here rather than only in the general scan above."""
assert key in _declared_keys(), (
f"{key} is read by the browser but is not declared in "
f"settings/properties.json -- see PR #562"
)
@@ -0,0 +1,84 @@
"""Guards for the integrity of downloaded release assets.
`webdl()` streams a release asset straight into a buffer that `unzip()` then
extracts over the install directory. The GitHub API already hands us the
asset's `digest` field, and `check_asset()` parses it into `installed_sha256`
-- but nothing ever compared it against the bytes on disk, so a corrupted or
substituted archive was extracted and executed unchallenged.
`verify_sha256()` closes that gap. These tests pin the behaviour that matters:
a mismatch must abort the install, and a verified buffer must still be
readable from position 0 so the extraction step keeps working.
"""
import hashlib
from io import BytesIO
import pytest
from camoufox.exceptions import CorruptedDownload
from camoufox.pkgman import verify_sha256
# Large enough to span several read() blocks, so a single-shot read()
# regression cannot pass by accident.
PAYLOAD = b"camoufox release asset" * 100_000
DIGEST = hashlib.sha256(PAYLOAD).hexdigest()
def test_matching_digest_is_accepted():
verify_sha256(BytesIO(PAYLOAD), DIGEST, "asset")
def test_digest_comparison_is_case_insensitive():
"""GitHub returns lowercase hex, but a hand-pinned digest may not be."""
verify_sha256(BytesIO(PAYLOAD), DIGEST.upper(), "asset")
@pytest.mark.parametrize(
"mutate",
[
pytest.param(lambda b: bytes([b[0] ^ 0xFF]) + b[1:], id="first-byte-flipped"),
pytest.param(lambda b: b[:-1] + bytes([b[-1] ^ 0x01]), id="last-bit-flipped"),
pytest.param(lambda b: b[:-1], id="truncated"),
pytest.param(lambda b: b + b"\x00", id="appended"),
pytest.param(lambda b: b"", id="empty"),
],
)
def test_tampered_payload_aborts_the_install(mutate):
"""Any deviation must raise -- extraction never gets to run."""
with pytest.raises(CorruptedDownload):
verify_sha256(BytesIO(mutate(PAYLOAD)), DIGEST, "asset")
def test_error_names_both_digests():
"""The message has to be actionable when someone hits this in the wild."""
with pytest.raises(CorruptedDownload) as exc:
verify_sha256(BytesIO(b"wrong"), DIGEST, "Camoufox v1.2.3")
msg = str(exc.value)
assert "Camoufox v1.2.3" in msg
assert DIGEST in msg
assert hashlib.sha256(b"wrong").hexdigest() in msg
@pytest.mark.parametrize("absent", [None, ""])
def test_missing_digest_does_not_block_the_install(absent):
"""Sources that publish no digest must stay installable, not hard-fail."""
verify_sha256(BytesIO(PAYLOAD), absent, "asset")
def test_buffer_is_rewound_for_extraction():
"""unzip() reads the same buffer next; leaving it at EOF yields an
empty archive rather than a loud failure."""
buf = BytesIO(PAYLOAD)
verify_sha256(buf, DIGEST, "asset")
assert buf.tell() == 0
assert buf.read() == PAYLOAD
def test_verifies_a_real_temporary_file(tmp_path):
"""The install path passes a NamedTemporaryFile, not a BytesIO."""
path = tmp_path / "asset.zip"
path.write_bytes(PAYLOAD)
with open(path, "rb") as f:
verify_sha256(f, DIGEST, "asset")
assert f.tell() == 0
+142
View File
@@ -0,0 +1,142 @@
"""
Tests that a device preset does not leak the host's operating system.
Run with:
cd pythonlib && python -m pytest tests/test_preset_appversion.py -v
The regression these guard (daijro/camoufox#744): from_preset() sets
navigator.userAgent, platform and oscpu from the captured device, but never
appVersion. Firefox reports appVersion as "5.0 (<OS token>)", so an unset value
falls through to the host's own -- and a page reading two properties sees a
Linux platform beside "5.0 (Macintosh)".
Measured before the fix on 152.0.4-beta.29, macOS host, os="linux" with
fingerprint_preset=True:
navigator.platform Linux x86_64
navigator.appVersion 5.0 (Macintosh) <- the host
The generated (browserforge) path already emits a coherent pair, which is why
this only shows up on the preset path.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from camoufox.fingerprints import _app_version_from_user_agent, from_preset # noqa: E402
# The tokens Firefox actually reports. Sampled from 800 browserforge
# fingerprints: Windows and Macintosh collapse to the family name, X11 keeps a
# distro token when the user agent carries one, and Android keeps its version.
_MAC = "5.0 (Macintosh)"
_WINDOWS = "5.0 (Windows)"
_X11 = "5.0 (X11)"
_X11_UBUNTU = "5.0 (X11; Ubuntu)"
def _preset(platform: str, user_agent: str) -> dict:
return {"navigator": {"platform": platform, "userAgent": user_agent}}
@pytest.mark.parametrize(
("platform", "user_agent", "expected"),
[
(
"Linux x86_64",
"Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0",
_X11,
),
(
"Win32",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
_WINDOWS,
),
(
"MacIntel",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0",
_MAC,
),
],
)
def test_app_version_follows_the_preset_platform(platform, user_agent, expected):
"""Every preset must carry the appVersion its own platform implies."""
config = from_preset(_preset(platform, user_agent))
assert config["navigator.appVersion"] == expected
def test_app_version_agrees_with_the_user_agent():
"""The pair a page compares must not contradict itself.
This is the check the issue is about: not that appVersion holds any
particular string, but that it names the same system as the userAgent
beside it.
"""
config = from_preset(
_preset("Linux x86_64", "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0")
)
assert "X11" in config["navigator.userAgent"]
assert config["navigator.appVersion"] == _X11
assert "Macintosh" not in config["navigator.appVersion"]
assert "Windows" not in config["navigator.appVersion"]
def test_a_preset_that_carries_its_own_app_version_keeps_it():
"""A captured value is the real device's, so it wins over the derived one."""
preset = _preset("Win32", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Firefox/152.0")
preset["navigator"]["appVersion"] = "5.0 (Windows NT 10.0; Win64; x64)"
config = from_preset(preset)
assert config["navigator.appVersion"] == "5.0 (Windows NT 10.0; Win64; x64)"
def test_an_unknown_platform_follows_its_user_agent():
"""The user agent is the authority, not the platform string beside it."""
config = from_preset(_preset("iPhone", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Gecko/20100101"))
assert config["navigator.appVersion"] == "5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"
@pytest.mark.parametrize(
("user_agent", "expected"),
[
# Every shape in the captured corpus, with the counts they appeared in.
("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0", _WINDOWS),
("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", _MAC),
("Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", _X11),
("Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", _X11_UBUNTU),
("Mozilla/5.0 (Android 16; Mobile; rv:152.0) Gecko/152.0 Firefox/152.0", "5.0 (Android 16)"),
],
)
def test_the_derivation_matches_what_firefox_reports(user_agent, expected):
"""Checked against 800 browserforge fingerprints; exact on every one."""
assert _app_version_from_user_agent(user_agent) == expected
def test_a_distro_token_survives():
"""Twenty of the bundled Linux presets say "X11; Ubuntu" in their user agent.
Deriving from the platform instead would flatten those to "X11" — a smaller
mismatch than the host leaking, but the same kind, and Firefox never emits it.
"""
config = from_preset(
_preset(
"Linux x86_64",
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0",
)
)
assert config["navigator.appVersion"] == _X11_UBUNTU
def test_a_user_agent_it_cannot_read_is_left_alone():
"""Better an absent value than an invented one."""
config = from_preset(_preset("Win32", "not a user agent"))
assert "navigator.appVersion" not in config
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Enforce that synthesized input is dispatched from exactly one place.
WHY THIS EXISTS
Between 2026-04 and 2026-09, four deadlocks shipped from the same invariant
being broken in four different ways -- exact-edge coordinates (#225), humanized
trajectory points that bypassed the endpoint's guard (#677), a zero-displacement
move, and the top-edge row (#751, #752). Each was fixed by adding one more
coordinate guard at one more call site.
The invariant:
A synthesized input event whose ack we await must reach the content
renderer -- and when it does not, we must stop waiting.
It is unenforceable by review, because a violation looks like ordinary
arithmetic and costs the entire browser process. #677 is the proof: restoring
the humanize trajectory meant writing a bounds check, and the check that got
written was a copy of the pre-#225 form -- reintroducing a fixed deadlock one
day before it was re-fixed. Nobody caught it in review; a grep would have.
So: one module owns the conversion, the bounds predicate and the ack wait, and
this check fails the build if anything else takes that job on. It needs no
browser build and runs in seconds, so it can gate every pull request.
python3 scripts/check-input-dispatch.py
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCAN_ROOT = ROOT / "additions" / "juggler"
CHOKEPOINT = "additions/juggler/input/MouseDispatch.js"
DOC = "docs/input-dispatch.md"
# The two exemptions are both the CONTENT process -- the other end of the wire,
# where the chokepoint's job does not exist:
#
# PageAgent dispatches drag events with coordinates that are already
# content-relative. No browser-element offset, no ack awaited.
# FrameTree is the ack PRODUCER: it observes the
# juggler-mouse-event-hit-renderer notification and emits the
# InputEvent carrying jugglerEventId. It waits for nothing.
#
# Both are narrow and deliberate. Anything in the parent process is covered.
CONTENT_DRAG = "additions/juggler/content/PageAgent.js"
CONTENT_ACK_SOURCE = "additions/juggler/content/FrameTree.js"
# (regex, what the code is doing, files exempt in addition to the chokepoint)
RULES = [
(r"\bjugglerSendMouseEvent\s*\(", "dispatches a synthesized mouse event", {CONTENT_DRAG}),
(r"\bsendWheelEvent\s*\(", "dispatches a synthesized wheel event", set()),
(r"\bjugglerEventId\b", "waits for a renderer ack", {CONTENT_ACK_SOURCE}),
(r"\bboundingBox\s*\.\s*(?:left|top)\b", "does browser-relative coordinate arithmetic", set()),
]
REMEDY = (
f"Route it through MouseDispatch ({CHOKEPOINT}): sendAcked() to dispatch and\n"
f" wait under a deadline, isInViewport() for the bounds predicate,\n"
f" toAbsolute() for the conversion. See {DOC}."
)
def main() -> int:
if not (ROOT / CHOKEPOINT).is_file():
print(f"FAIL: the chokepoint {CHOKEPOINT} is missing.")
print(" If it moved, update CHOKEPOINT in this script and in " + DOC + ".")
return 1
compiled = [(re.compile(p), what, exempt) for p, what, exempt in RULES]
violations = []
for path in sorted(SCAN_ROOT.rglob("*.js")):
rel = path.relative_to(ROOT).as_posix()
if rel == CHOKEPOINT or path.name.endswith(".bak"):
continue
for lineno, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
if line.lstrip().startswith(("//", "*", "/*")):
continue
for pattern, what, exempt in compiled:
if rel in exempt:
continue
if pattern.search(line):
violations.append((rel, lineno, what, line.strip()))
if not violations:
scanned = sum(1 for _ in SCAN_ROOT.rglob("*.js"))
print(f"input-dispatch: ok -- {scanned} files scanned, all synthesized input "
f"goes through {CHOKEPOINT}")
return 0
print("input-dispatch: FAILED\n")
for rel, lineno, what, line in violations:
print(f" {rel}:{lineno} {what} outside the chokepoint")
print(f" {line}")
print(f"\n {REMEDY}")
print(f"\n{len(violations)} violation(s).")
return 1
if __name__ == "__main__":
sys.exit(main())
+2 -1
View File
@@ -18,7 +18,8 @@ def ensure_bundle() -> Path:
print("ERROR: build-tester/node_modules not found. Run 'npm install' in build-tester/ first.", file=sys.stderr)
sys.exit(1)
esbuild = BUILD_TESTER_DIR / "node_modules" / ".bin" / "esbuild"
esbuild_name = "esbuild.cmd" if sys.platform == "win32" else "esbuild"
esbuild = BUILD_TESTER_DIR / "node_modules" / ".bin" / esbuild_name
print("Building checks bundle (first run)...")
entry = BUILD_TESTER_DIR / "src" / "lib" / "checks" / "index.ts"
result = subprocess.run(
+188
View File
@@ -0,0 +1,188 @@
<#
.SYNOPSIS
Windows equivalent of run_tests.sh for Camoufox service tests.
.DESCRIPTION
Orchestrates two test phases against locally compiled and/or fetched
Camoufox binaries on Windows, mirroring the behaviour of run_tests.sh.
.PARAMETER BrowserVersion
Camoufox version specifier (default: official/stable)
e.g. official/prerelease/146.0.1-beta.50
.PARAMETER ProfileCount
Number of profiles to test (1-6, default: 6)
.PARAMETER Proxies
Path to proxies file (default: proxies.txt next to this script)
.PARAMETER Headful
Run with visible browser window
.PARAMETER NoCert
Skip certificate generation
.PARAMETER SaveCert
Save certificate text to this file path
.PARAMETER Binary
Which binary phase(s) to run: local | fetched | both (default: both)
.EXAMPLE
.\run_tests.ps1
.\run_tests.ps1 -BrowserVersion official/prerelease/146.0.1-beta.50 -Headful
.\run_tests.ps1 -Binary local
.\run_tests.ps1 -Binary fetched -ProfileCount 3
#>
param(
[string]$BrowserVersion = "official/stable",
[int] $ProfileCount = 6,
[string]$Proxies = "",
[switch]$Headful,
[switch]$NoCert,
[string]$SaveCert = "",
[string]$Binary = "both"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Ensure the console and Python subprocess both handle UTF-8 output correctly.
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONIOENCODING = "utf-8"
# --- Resolve paths ---
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$BuildTesterDir = [IO.Path]::Combine($ScriptDir, "..", "build-tester")
$PythonlibDir = [IO.Path]::Combine($ScriptDir, "..", "pythonlib")
$VenvDir = Join-Path $ScriptDir ".venv"
$PythonExe = [IO.Path]::Combine($VenvDir, "Scripts", "python.exe")
$PipExe = [IO.Path]::Combine($VenvDir, "Scripts", "pip.exe")
$ProxiesFile = if ($Proxies) { $Proxies } else { Join-Path $ScriptDir "proxies.txt" }
# --- Validate -Binary ---
if ($Binary -notin @("local", "fetched", "both")) {
Write-Error "ERROR: -Binary must be 'local', 'fetched', or 'both' (got: $Binary)"
exit 1
}
Write-Host "==> Browser version: $BrowserVersion"
Write-Host "==> Profile count: $ProfileCount"
Write-Host "==> Binary mode: $Binary"
# --- npm deps (esbuild for TypeScript bundle) ---
$NodeModules = Join-Path $BuildTesterDir "node_modules"
if (-not (Test-Path $NodeModules)) {
Write-Host "==> Installing build-tester npm dependencies..."
Push-Location $BuildTesterDir
npm install --silent
Pop-Location
}
# --- Python venv ---
if (-not (Test-Path $VenvDir)) {
Write-Host "==> Creating virtual environment..."
python -m venv $VenvDir
}
# --- Build camoufox wheel ---
Write-Host "==> Building camoufox wheel from $PythonlibDir ..."
& $PipExe install -q build
$DistDir = [IO.Path]::Combine($PythonlibDir, "dist")
if (Test-Path $DistDir) {
Remove-Item -Recurse -Force $DistDir
}
Push-Location $PythonlibDir
& $PythonExe -m build --wheel -o dist | Out-Null
Pop-Location
# --- Install wheel ---
Write-Host "==> Installing camoufox from local wheel..."
try { & $PipExe uninstall -y camoufox cloverlabs-camoufox 2>&1 | Out-Null } catch {}
$Wheel = Get-ChildItem ([IO.Path]::Combine($PythonlibDir, "dist", "*.whl")) | Select-Object -First 1
if (-not $Wheel) {
Write-Error "ERROR: No wheel found in $PythonlibDir\dist"
exit 1
}
& $PipExe install -q --force-reinstall $Wheel.FullName
# --- Locate locally compiled Windows binary ---
# Build output: ../camoufox-*/obj-*-windows-msvc/dist/bin/camoufox.exe
$LocalBin = $null
if ($Binary -ne "fetched") {
$GlobPattern = [IO.Path]::Combine($ScriptDir, "..", "camoufox-*", "obj-*-windows-msvc", "dist", "bin", "camoufox.exe")
$Candidates = Get-ChildItem $GlobPattern -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending
if ($Candidates) {
$LocalBin = $Candidates[0].FullName
}
if (-not $LocalBin) {
if ($Binary -eq "local") {
Write-Error "ERROR: -Binary local requested but no local build found at $GlobPattern"
exit 1
}
Write-Host "==> No local build found -- skipping local-binary phase"
}
}
# --- Build common args for run_tests.py ---
$CommonArgs = [System.Collections.Generic.List[string]]::new()
$CommonArgs.AddRange([string[]]@("--profile-count", $ProfileCount, "--proxies", $ProxiesFile))
if ($Headful) { $CommonArgs.Add("--headful") }
if ($NoCert) { $CommonArgs.Add("--no-cert") }
if ($SaveCert) { $CommonArgs.AddRange([string[]]@("--save-cert", $SaveCert)) }
$LocalRc = 0
$FetchedRc = 0
# --- Phase 1: locally compiled binary ---
if ($LocalBin) {
Write-Host ""
Write-Host ("=" * 60)
Write-Host " PHASE 1/2 -- Local binary: $LocalBin"
Write-Host ("=" * 60)
$Phase1Args = [System.Collections.Generic.List[string]]::new()
$Phase1Args.Add([IO.Path]::Combine($ScriptDir, "run_tests.py"))
$Phase1Args.AddRange([string[]]@("--executable-path", $LocalBin))
$Phase1Args.AddRange($CommonArgs)
& $PythonExe $Phase1Args
$LocalRc = $LASTEXITCODE
}
# --- Phase 2: fetched binary ---
if ($Binary -ne "local") {
Write-Host ""
Write-Host ("=" * 60)
Write-Host " PHASE 2/2 -- Fetched binary: $BrowserVersion"
Write-Host ("=" * 60)
Write-Host "==> Setting browser version: $BrowserVersion"
& $PythonExe -m camoufox set $BrowserVersion
Write-Host "==> Fetching browser..."
& $PythonExe -m camoufox fetch
$Phase2Args = [System.Collections.Generic.List[string]]::new()
$Phase2Args.Add([IO.Path]::Combine($ScriptDir, "run_tests.py"))
$Phase2Args.AddRange([string[]]@("--browser-version", $BrowserVersion))
$Phase2Args.AddRange($CommonArgs)
& $PythonExe $Phase2Args
$FetchedRc = $LASTEXITCODE
}
# --- Combined result ---
Write-Host ""
Write-Host ("=" * 60)
Write-Host " COMBINED RESULT"
Write-Host ("=" * 60)
if ($LocalBin) { Write-Host " Local binary: exit $LocalRc" }
if ($Binary -ne "local") { Write-Host " Fetched binary: exit $FetchedRc" }
if (($LocalRc -ne 0) -or ($FetchedRc -ne 0)) {
exit 1
}
exit 0
+5
View File
@@ -25,6 +25,11 @@ import argparse
import asyncio
import sys
from datetime import datetime, timezone
# Ensure Unicode box-drawing characters render correctly on Windows consoles.
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
from typing import Optional
+1
View File
@@ -88,6 +88,7 @@
"mediaDevices:webcams": "int[>=0]",
"mediaDevices:speakers": "int[>=0]",
"mediaDevices:enabled": "bool",
"media:spoof_codecs": "bool",
"webGl:renderer$__WEBGL": "str",
"webGl:vendor$__WEBGL": "str",
+1
View File
@@ -97,6 +97,7 @@
{ "property": "mediaDevices:webcams", "type": "uint" },
{ "property": "mediaDevices:speakers", "type": "uint" },
{ "property": "mediaDevices:enabled", "type": "bool" },
{ "property": "media:spoof_codecs", "type": "bool" },
{ "property": "allowMainWorld", "type": "bool" },
{ "property": "disableWorldIsolation", "type": "bool" },
{ "property": "allowAddonNewtab", "type": "bool" },
+71
View File
@@ -0,0 +1,71 @@
<!doctype html><meta charset="utf-8"><title>Camoufox touch reference</title>
<style>
body{font:14px system-ui;margin:0;padding:16px;background:#0f1310;color:#e8ebe2}
h2{font-size:15px;margin:18px 0 8px}
#pad{height:230px;border:2px dashed #5fb5ac;border-radius:10px;display:flex;
align-items:center;justify-content:center;color:#5fb5ac;font-size:17px;
touch-action:none;user-select:none;margin-bottom:12px}
textarea{width:100%;height:260px;background:#1b1f18;color:#cfe;border:1px solid #2e342a;
border-radius:8px;font:11px ui-monospace,monospace;padding:10px}
button{font:600 14px system-ui;padding:9px 16px;border-radius:7px;border:1px solid #5fb5ac;
background:#5fb5ac;color:#0f1310;cursor:pointer;margin-right:8px}
.n{color:#98a090;font-size:12px}
</style>
<h2>1. Static signals — captured automatically</h2>
<div class="n" id="stat">reading…</div>
<h2>2. Touch capture — please do these three, in order, inside the box</h2>
<div class="n">a) one-finger tap &nbsp;·&nbsp; b) two-finger tap &nbsp;·&nbsp; c) one-finger drag across the box</div>
<div id="pad">touch here</div>
<button id="copy">Copy everything</button><button id="reset">Clear touches</button>
<span class="n" id="count">0 events</span>
<h2>3. Paste this back</h2>
<textarea id="out" readonly></textarea>
<script>
function mq(q){return matchMedia(q).matches}
var stat={
ua:navigator.userAgent, platform:navigator.platform, ffVersion:navigator.userAgent.match(/Firefox\/([\d.]+)/)?.[1],
maxTouchPoints:navigator.maxTouchPoints, ontouchstart:('ontouchstart' in window),
TouchEvent:typeof window.TouchEvent!=='undefined', Touch:typeof window.Touch!=='undefined',
createTouch:typeof document.createTouch==='function',
PointerEvent:typeof window.PointerEvent!=='undefined',
pointer_coarse:mq('(pointer: coarse)'), pointer_fine:mq('(pointer: fine)'), pointer_none:mq('(pointer: none)'),
anyPointer_coarse:mq('(any-pointer: coarse)'), anyPointer_fine:mq('(any-pointer: fine)'), anyPointer_none:mq('(any-pointer: none)'),
hover_hover:mq('(hover: hover)'), hover_none:mq('(hover: none)'),
anyHover_hover:mq('(any-hover: hover)'), anyHover_none:mq('(any-hover: none)'),
screen:screen.width+'x'+screen.height, avail:screen.availWidth+'x'+screen.availHeight,
dpr:devicePixelRatio, colorDepth:screen.colorDepth, hardwareConcurrency:navigator.hardwareConcurrency
};
document.getElementById('stat').textContent=JSON.stringify(stat,null,1);
var log=[],t0=null;
function rec(e){
if(t0===null)t0=performance.now();
var r={t:+(performance.now()-t0).toFixed(1),type:e.type};
if(e instanceof PointerEvent){
r.pointerType=e.pointerType; r.isPrimary=e.isPrimary; r.pressure=e.pressure;
r.width=e.width; r.height=e.height; r.tiltX=e.tiltX; r.tiltY=e.tiltY; r.twist=e.twist;
r.pointerId=e.pointerId; r.buttons=e.buttons;
}
if(window.TouchEvent&&e instanceof TouchEvent){
r.touches=e.touches.length; r.changed=e.changedTouches.length;
r.pts=[].map.call(e.changedTouches,function(t){return {
id:t.identifier,radiusX:t.radiusX,radiusY:t.radiusY,force:t.force,rotationAngle:t.rotationAngle};});
}
if(e.type.indexOf('mouse')===0){ r.buttons=e.buttons; r.detail=e.detail; }
log.push(r); render();
}
['pointerdown','pointermove','pointerup','pointercancel',
'touchstart','touchmove','touchend','touchcancel',
'mousedown','mousemove','mouseup','click'].forEach(function(t){
document.getElementById('pad').addEventListener(t,rec,{passive:true});
});
function render(){
document.getElementById('count').textContent=log.length+' events';
document.getElementById('out').value=JSON.stringify({static:stat,events:log},null,1);
}
render();
document.getElementById('copy').onclick=function(){
var o=document.getElementById('out'); o.select(); document.execCommand('copy');
this.textContent='Copied';
};
document.getElementById('reset').onclick=function(){log=[];t0=null;render();};
</script>
+130
View File
@@ -0,0 +1,130 @@
"""
Verify the renderer-ack wait is bounded (daijro/camoufox#751, #752).
This guards the backstop itself -- the one mechanism that makes an undelivered
input event survivable rather than fatal.
Camoufox dispatches synthesized mouse events inside `activateAndRun()`
(additions/juggler/TargetRegistry.js), which serializes every dispatch on a
*process-global* promise chain. Each dispatch awaits a hit-renderer ack. Before
the backstop that wait was unbounded, so an ack that never arrived did not lose
one event -- it wedged every later input event in the process, in every tab,
permanently, at 0% CPU with no diagnostic. All four shipped deadlocks were that
failure with four different triggers; see docs/input-dispatch.md.
`MouseDispatch.sendAcked()` now waits at most `kAckDeadlineMs`, then drops the
event with a warning and lets the chain advance.
HOW THIS TEST WORKS
The ack is delivered from the content main thread, so blocking that thread
delays it by exactly the block duration -- a legitimate mechanism for producing
a late ack, with no test-only hook in production code. The page is made to block
for well over the deadline; a bounded wait returns in about the deadline, an
unbounded one waits for the whole block.
The gap is what makes the assertion meaningful: with a ~5s deadline and a 40s
block, "returned in under 20s" cannot be satisfied by an unbounded wait, and
does not depend on the exact deadline value.
Dropping that event is the correct, chosen behaviour: warn and continue. The
test therefore asserts recovery, not delivery -- the move may legitimately be
lost, but the browser must still be usable afterwards.
Run against a specific build:
CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python3 tests/patches/input-ack-backstop.py
What PASS means:
* a dispatch whose ack is late by far more than the deadline returns in
roughly the deadline, not in the block duration;
* once the block clears, input still works -- the chain advanced rather
than being abandoned mid-slot.
Before the backstop the first move waits out the entire block.
"""
import asyncio
import os
import sys
import time
from camoufox.async_api import AsyncCamoufox
# Far longer than kAckDeadlineMs (5s), so the two outcomes cannot be confused.
BLOCK_MS = 40000
# Generous over the deadline, far under the block.
BOUNDED_S = 20
RECOVERY_TIMEOUT_S = 30
EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH")
def _launch_kwargs():
kwargs = dict(headless=True, os="windows", humanize=False)
if EXECUTABLE_PATH:
kwargs["executable_path"] = EXECUTABLE_PATH
return kwargs
async def main() -> int:
print("\n=== bounded renderer-ack wait ===")
async with AsyncCamoufox(**_launch_kwargs()) as browser:
page = await browser.new_page()
await page.set_content('<body style="margin:0;height:1200px"></body>')
await page.evaluate(
"window.__moves=0;addEventListener('mousemove',()=>window.__moves++)")
await asyncio.wait_for(page.mouse.move(300, 300), timeout=RECOVERY_TIMEOUT_S)
# Block the content main thread. Deliberately not awaited: the block has
# to still be running when the move below is dispatched.
blocker = asyncio.ensure_future(page.evaluate(
f"(()=>{{const end=Date.now()+{BLOCK_MS};while(Date.now()<end);}})()"))
await asyncio.sleep(0.25)
print(f" content main thread blocked for {BLOCK_MS}ms; dispatching a move",
flush=True)
t0 = time.time()
try:
await asyncio.wait_for(page.mouse.move(500, 400), timeout=BOUNDED_S)
except asyncio.TimeoutError:
print(f" FAIL: still waiting after {BOUNDED_S}s")
print(
"\n The ack wait is unbounded. An event that never reaches the\n"
" renderer will wedge the process-global activation chain forever.\n"
" Fix: bound it in MouseDispatch.sendAcked() via\n"
" EventWatcher.ensureEventWithin(). See docs/input-dispatch.md.\n")
blocker.cancel()
return 1
waited = time.time() - t0
print(f" returned after {waited:.1f}s (block runs for {BLOCK_MS / 1000:.0f}s)",
flush=True)
# Let the block finish, then prove the chain advanced rather than died.
try:
await asyncio.wait_for(blocker, timeout=BLOCK_MS / 1000 + 15)
except asyncio.TimeoutError:
print(" FAIL: the blocking script never finished")
return 1
except Exception:
pass # a lost evaluate is fine; the chain is what matters
before = await asyncio.wait_for(
page.evaluate("window.__moves"), timeout=RECOVERY_TIMEOUT_S)
try:
await asyncio.wait_for(page.mouse.move(700, 500), timeout=RECOVERY_TIMEOUT_S)
await asyncio.wait_for(page.mouse.click(650, 450), timeout=RECOVERY_TIMEOUT_S)
after = await asyncio.wait_for(
page.evaluate("window.__moves"), timeout=RECOVERY_TIMEOUT_S)
except asyncio.TimeoutError:
print(" FAIL: input dead after the late ack -- the chain is poisoned")
return 1
if after <= before:
print(" FAIL: the browser accepted moves but the page saw none")
return 1
print(f" input still live afterwards ({after - before} mousemove seen)")
print("\n PASS: the ack wait is bounded and the chain recovers\n")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+135
View File
@@ -0,0 +1,135 @@
"""
Sweep the viewport boundary for coordinates that never reach the renderer.
This replaces hand-picked edge targets, which are what let four deadlocks
through. Each of the earlier tests probed the coordinates that had just been
found broken, so the next variant was always outside the list:
humanize-edge-deadlock.py probes the right and bottom edges only, so the
near edge (#751) passed it cleanly
humanize-mouse-trajectory.py pins os="linux", the one spoofed OS whose
chrome offset is immune to #751
The failure they all share: a synthesized event that hit-tests outside the
content widget fires as an exit event rather than eMouseMove, no
juggler-mouse-event-hit-renderer ack is produced, and -- because dispatch is
serialized on activateAndRun()'s process-global chain -- that one missing ack
wedges every later input event in the process. See docs/input-dispatch.md.
Rather than guess which coordinates do that, sweep the ring:
x in {0, 1, w/2, w-2, w-1} x y in {0, 1, h/2, h-2, h-1}
across every spoofed OS (the chrome offset that decides #751 is a function of
it: windows 51.4, macos 53.1, linux 56.5) and with humanize both off and on.
Each point must be *delivered*, not merely survived: the page has to observe a
mousemove. A point that is dropped without hanging is still a bug -- it is the
"click did nothing" half of #752 -- and asserting only "did not time out" would
miss it.
This test depends on the ack backstop (MouseDispatch kAckDeadlineMs) to run at
all. Without it the first undelivered coordinate wedges the browser and the
sweep dies there, reporting one point instead of the whole failing set.
Run against a specific build:
CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python3 tests/patches/mouse-boundary-sweep.py
What PASS means: every ring coordinate, on every spoofed OS, humanized and not,
was acked and seen by the page; and input is still live at the end of each run.
"""
import asyncio
import os
import sys
from camoufox.async_api import AsyncCamoufox
SPOOFED_OSES = ["windows", "macos", "linux"]
# Well inside the viewport, so every dispatched point is a real displacement.
INTERIOR = (0.34, 0.34)
# Per-point ceiling. Comfortably above the 5s ack deadline, so a dropped point
# shows up as "not delivered" with a fast return rather than as a timeout here.
POINT_TIMEOUT_S = 25
EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH")
RECORDER = "window.__moves=0;addEventListener('mousemove',()=>window.__moves++)"
def _launch_kwargs(humanize, spoofed_os):
kwargs = dict(headless=True, os=spoofed_os, humanize=humanize)
if EXECUTABLE_PATH:
kwargs["executable_path"] = EXECUTABLE_PATH
return kwargs
def _ring(w, h):
xs = [0, 1, w // 2, w - 2, w - 1]
ys = [0, 1, h // 2, h - 2, h - 1]
return [(x, y) for y in ys for x in xs]
async def _sweep(spoofed_os, humanize) -> list:
"""Returns the coordinates that did not reach the renderer."""
undelivered = []
async with AsyncCamoufox(**_launch_kwargs(humanize, spoofed_os)) as browser:
page = await browser.new_page()
await page.set_content('<body style="margin:0;height:1600px"></body>')
await page.evaluate(RECORDER)
vp = await page.evaluate("({w:innerWidth,h:innerHeight})")
w, h = vp["w"], vp["h"]
home = (int(w * INTERIOR[0]), int(h * INTERIOR[1]))
points = _ring(w, h)
print(f" [{spoofed_os}, humanize={humanize}] {w}x{h}: "
f"{len(points)} ring points", flush=True)
for x, y in points:
await asyncio.wait_for(page.mouse.move(*home), timeout=POINT_TIMEOUT_S)
before = await page.evaluate("window.__moves")
try:
await asyncio.wait_for(page.mouse.move(x, y), timeout=POINT_TIMEOUT_S)
except asyncio.TimeoutError:
undelivered.append(((x, y), "hung -- the ack backstop did not fire"))
# The chain is wedged; nothing after this can run.
return undelivered
after = await asyncio.wait_for(
page.evaluate("window.__moves"), timeout=POINT_TIMEOUT_S)
if after == before:
undelivered.append(((x, y), "dispatched, but the page saw no mousemove"))
# Prove the chain is not poisoned rather than trusting the absence of a
# timeout above.
try:
await asyncio.wait_for(page.mouse.move(*home), timeout=POINT_TIMEOUT_S)
await asyncio.wait_for(page.mouse.click(*home), timeout=POINT_TIMEOUT_S)
except asyncio.TimeoutError:
undelivered.append((home, "input dead after the sweep"))
return undelivered
async def main() -> int:
print("\n=== viewport boundary sweep ===")
failures = []
for spoofed_os in SPOOFED_OSES:
for humanize in (False, True):
bad = await _sweep(spoofed_os, humanize)
for point, why in bad:
failures.append((spoofed_os, humanize, point, why))
print(f" FAIL {point} {why}", flush=True)
if not bad:
print(" ok", flush=True)
if failures:
print(f"\n {len(failures)} coordinate(s) did not reach the renderer.\n"
" Every one of these wedges the process-global activation chain on a\n"
" build without the ack backstop. Fix the conversion in\n"
" additions/juggler/input/MouseDispatch.js -- see docs/input-dispatch.md.\n")
return 1
print("\n PASS: every ring coordinate acked and observed, on every spoofed OS\n")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+227
View File
@@ -0,0 +1,227 @@
"""
Verify a mouse event on the viewport's top edge does not deadlock (daijro/camoufox#751, #752).
Camoufox dispatches synthesized mouse events inside `activateAndRun()`
(additions/juggler/TargetRegistry.js), which serializes every dispatch on a
*process-global* promise chain. Each dispatch awaits a `hit-renderer` ack from
the content process. If an ack never arrives, the callback never returns, the
global chain never advances, and every later input event in the process hangs
behind it forever. Two triggers were already fixed and are guarded by
humanize-edge-deadlock.py (the far edges, x==width / y==height, #225) and
noop-mousemove-deadlock.py (a zero-displacement move). This is the third: the
*near* edge, y==0.
MECHANISM
`sendOne()` in PageHandler.js dispatches at `eventY + boundingBox.top`, so a
relative y of 0 dispatches at absolute y == `boundingBox.top` exactly -- the
first row of the content area. That row is only partly covered whenever the
chrome above it is a fractional number of CSS pixels tall, and the widget rounds
the coordinate to a whole device row before hit-testing it. When
`round(top) < top` the rounded row still belongs to chrome, the event fires as
an exit event rather than eMouseMove, no ack is produced, and the chain wedges.
That makes it a deterministic property of the chrome height, which Camoufox
varies with the spoofed OS. Measured on this build by logging `boundingBox` at
the dispatch site, and pairing each offset with the outcome of a single move:
os boundingBox.top rounds to page.mouse.move(31, 0)
windows 51.4 51 (above) hangs, 5/5
macos 53.1 53 (above) hangs, 5/5
linux 56.5 57 (below) completes, 8/8
So the default randomized fingerprint reaches it on most launches, and a run
that spoofs Linux never does -- which is why this went unnoticed while the
far-edge guards were in place. Relative x == 0 is unaffected for the same
reason: `boundingBox.left` is a whole 0, so it needs no rounding.
WHY NOT WIDEN THE BOUNDS CHECK
The far edges were fixed by treating them as out-of-viewport. 0 cannot be: it is
a legitimate in-viewport coordinate a caller may ask for, and the out-of-viewport
branch silently drops mousedown/mouseup, so widening the check would turn the
hang into a click that reports success and fires nothing (#752). The fix snaps
the dispatched coordinate to the first whole pixel inside the browser element,
which stays within content pixel 0 while landing clear of the boundary.
COVERAGE
Both dispatch paths reach the same conversion, so both are covered:
* direct dispatch -- `page.mouse.move(x, 0)`, humanize off, and the humanized
move's explicit endpoint. Deterministic on an affected chrome offset.
* humanized trajectory -- how it is actually hit in the field. Every
PageHandler starts at `_lastTrackedPos = {x: 0, y: 0}` (PageHandler.js:86),
so a session's FIRST humanized move always departs from the top-left corner,
and with the +/-80px knot boundary from MouseTrajectories.hpp the curve rides
the y==0 row. On a stock build a first humanized click hung on 5 of 20 cold
pages; all five had dispatched a point at y==0 and the 15 that completed had
dispatched none. Sampled here rather than asserted deterministically, since
whether the curve touches the row is random.
Run against a specific build:
CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python3 tests/patches/near-edge-mouse-deadlock.py
What PASS means:
* a move onto the top edge completes on every spoofed OS, and the page
actually observes it -- an event swallowed by chrome leaves no mousemove;
* the browser still responds to input afterwards, proving the global chain
is not poisoned;
* a session's first humanized click completes on repeated cold pages.
Before the fix the first direct move times out; after it, every move completes.
"""
import asyncio
import os
import sys
from camoufox.async_api import AsyncCamoufox
# The chrome height, and so whether the top row rounds into chrome, depends on
# the spoofed OS. Cover all three rather than assuming which one this host's
# chrome puts on the wrong side of the boundary.
SPOOFED_OSES = ["windows", "macos", "linux"]
# Relative y == 0 is the deadlock coordinate. x is varied only to show it is the
# whole row that is poisoned, not one particular pixel.
TOP_EDGE_TARGETS = [(31, 0), (0, 0), (500, 0)]
# Fresh pages for the humanized half: each resets _lastTrackedPos to (0, 0), so
# each is an independent chance for the first trajectory to ride the top edge.
COLD_PAGES = 4
# Close enough to the top that a trajectory from (0, 0) sweeps the y==0 row.
HUMANIZED_TARGET = (660, 186)
INTERIOR = (250, 250)
TIMEOUT_S = 20
EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH")
RECORDER = "window.__moves=0;addEventListener('mousemove',()=>window.__moves++)"
def _launch_kwargs(humanize, spoofed_os):
kwargs = dict(headless=True, os=spoofed_os, humanize=humanize)
if EXECUTABLE_PATH:
kwargs["executable_path"] = EXECUTABLE_PATH
return kwargs
def _deadlock_report(what):
print(
f"\n DEADLOCK: {what} produced no hit-renderer ack. The global activation\n"
" chain is now wedged -- all further input hangs.\n"
" Fix: snap the dispatched coordinate to the first whole pixel inside the\n"
" browser element in PageHandler.js sendOne(), so a relative 0 does not\n"
" land on the fractional chrome/content boundary.\n"
)
async def _direct_moves() -> bool:
"""A plain move onto the top edge must complete and be seen by the page."""
print("\n=== direct moves onto the top edge (humanize off) ===")
for spoofed_os in SPOOFED_OSES:
async with AsyncCamoufox(**_launch_kwargs(False, spoofed_os)) as browser:
page = await browser.new_page()
await page.set_content('<body style="margin:0;height:1200px"></body>')
await page.evaluate(RECORDER)
# Start from an interior point so the move under test is a real
# displacement, not a no-op skipped before dispatch.
await asyncio.wait_for(page.mouse.move(*INTERIOR), timeout=TIMEOUT_S)
for x, y in TOP_EDGE_TARGETS:
label = f" [{spoofed_os}] move -> ({x}, {y})"
before = await page.evaluate("window.__moves")
try:
await asyncio.wait_for(page.mouse.move(x, y), timeout=TIMEOUT_S)
except asyncio.TimeoutError:
print(f"{label} FAIL: no ack after {TIMEOUT_S}s")
_deadlock_report(f"a mousemove at ({x}, {y})")
return False
after = await asyncio.wait_for(
page.evaluate("window.__moves"), timeout=TIMEOUT_S
)
if after == before:
print(f"{label} FAIL: dispatched but the page saw no mousemove")
print(
"\n The event was delivered outside the content area. It did not\n"
" hang this time, but it never reached the renderer either.\n"
)
return False
print(f"{label} ok")
await asyncio.wait_for(page.mouse.move(*INTERIOR), timeout=TIMEOUT_S)
# The chain survived: prove input still works rather than trusting the
# absence of a timeout above.
try:
await asyncio.wait_for(page.mouse.move(400, 300), timeout=TIMEOUT_S)
live = await asyncio.wait_for(
page.evaluate("window.__moves"), timeout=TIMEOUT_S
)
except asyncio.TimeoutError:
print(f" [{spoofed_os}] FAIL: unresponsive after the edge moves")
return False
if not live:
print(f" [{spoofed_os}] FAIL: no mousemove observed at all")
return False
return True
async def _humanized() -> bool:
"""The humanize path reaches the same conversion, by endpoint and by curve."""
print("\n=== humanized moves (humanize on) ===")
for spoofed_os in SPOOFED_OSES:
async with AsyncCamoufox(**_launch_kwargs(True, spoofed_os)) as browser:
# A humanized move whose destination IS the top edge: the trajectory's
# explicit endpoint dispatch is unconditional, so this is the
# deterministic half.
page = await browser.new_page()
await page.set_content('<body style="margin:0;height:1200px"></body>')
await page.evaluate(RECORDER)
await asyncio.wait_for(page.mouse.move(*INTERIOR), timeout=TIMEOUT_S)
label = f" [{spoofed_os}] humanized move -> (500, 0)"
try:
await asyncio.wait_for(page.mouse.move(500, 0), timeout=TIMEOUT_S)
except asyncio.TimeoutError:
print(f"{label} FAIL: no ack after {TIMEOUT_S}s")
_deadlock_report("a humanized move ending at y==0")
return False
print(f"{label} ok")
await page.close()
# Cold pages: the trajectory departs (0, 0) and may ride the y==0 row.
for i in range(1, COLD_PAGES + 1):
label = f" [{spoofed_os}] cold page {i}/{COLD_PAGES}: click -> {HUMANIZED_TARGET}"
page = await browser.new_page()
await page.set_content(
'<button id="b" style="position:absolute;left:600px;top:160px;'
'width:120px;height:52px">go</button>'
)
await page.evaluate(
"window.__clicked=false;document.getElementById('b')"
".addEventListener('click',()=>window.__clicked=true)"
)
try:
# click() moves first, so this is the session's first
# trajectory -- generated from the initial (0, 0).
await asyncio.wait_for(page.click("#b"), timeout=TIMEOUT_S)
clicked = await asyncio.wait_for(
page.evaluate("window.__clicked"), timeout=TIMEOUT_S
)
except asyncio.TimeoutError:
print(f"{label} FAIL: no ack after {TIMEOUT_S}s")
_deadlock_report("a humanized trajectory point at y==0")
return False
if not clicked:
print(f"{label} FAIL: click completed but the target never fired")
return False
print(f"{label} ok")
await page.close()
return True
async def main() -> int:
if not await _direct_moves():
return 1
if not await _humanized():
return 1
print("\n PASS: top-edge moves completed and were seen; input still live\n")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+148
View File
@@ -0,0 +1,148 @@
r"""
Verify the search service still initializes (no-search-engines.patch).
Camoufox ships no search engines: no-search-engines.patch short-circuits
SearchEngineSelector.#getConfiguration() with a hardcoded stub instead of
fetching from Remote Settings, which is dead anyway because camoufox.cfg sets
services.settings.server to "".
The stub's *shape* is load-bearing, and that is what this guards. The selector
is the Rust SearchEngineSelector, which deserializes search-config **v2**:
#[serde(tag = "recordType", rename_all = "camelCase")]
enum JSONSearchConfigurationRecords { ... }
`recordType` is the enum's tag, so a record without it aborts the entire
document with `missing field \`recordType\``. The stub used to be a v1 record
(`appliesTo`/`webExtension`), so setSearchConfig() threw on every single
launch, #init() died, and the browser ran with no search service at all --
which also takes out the urlbar's heuristic result, so history and autofill
never render. See daijro/camoufox#737.
That shipped broken in beta.28, .29 and .30 without anything noticing, because
the failure is a console error on a browser that otherwise starts fine. Hence
this test.
Note that "no engines" cannot be expressed as an empty configuration:
getEngineConfiguration() rejects `[]` with "Failed to get engine data from
Remote Settings", and SearchSettings refuses to write without an engine. So the
stub carries one inert engine, and this test asserts both halves -- that init
completes, AND that the only engine present is that inert one.
Run from any venv (no playwright needed -- this drives the binary directly):
python tests/patches/search-service-init.py
python tests/patches/search-service-init.py --binary /path/to/camoufox-bin
"""
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import List, Optional
REPO_ROOT = Path(__file__).resolve().parents[2]
# Long enough for the search service to init and write settings; the browser
# does not exit on its own with -headless about:blank, so it is killed after.
LAUNCH_TIMEOUT_S = 60
# Any of these appearing as an added engine means the "no search engines"
# stance has been lost.
REAL_ENGINES = ("Google", "Bing", "DuckDuckGo", "Perplexity", "Wikipedia",
"Yahoo", "Ecosia", "Qwant", "Baidu", "Yandex")
ADDED_ENGINE_RE = re.compile(r'"#addEngineToStore: Adding engine:" "([^"]*)"')
def resolve_binary(argv: List[str]) -> 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
def launch_and_capture(binary: Path, profile: Path) -> str:
"""Start the browser on a fresh profile with search logging, return output."""
(profile / "user.js").write_text('user_pref("browser.search.log", true);\n')
proc = subprocess.Popen(
[str(binary), "-profile", str(profile), "-headless", "-no-remote", "about:blank"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace",
)
try:
out, _ = proc.communicate(timeout=LAUNCH_TIMEOUT_S)
return out
except subprocess.TimeoutExpired:
# Expected: about:blank never exits. Take what was logged.
proc.kill()
out, _ = proc.communicate()
return out
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}")
with tempfile.TemporaryDirectory(prefix="camoufox-search-") as tmp:
profile = Path(tmp)
log = launch_and_capture(binary, profile)
settings_written = (profile / "search.json.mozlz4").exists()
settings_blob = (
(profile / "search.json.mozlz4").read_bytes() if settings_written else b""
)
failures = []
# 1. The deserialization error this patch has historically caused.
record_type_errors = log.count("missing field `recordType`")
if record_type_errors:
failures.append(
f"the config stub failed to deserialize: {record_type_errors} "
"'missing field `recordType`' error(s) -- the stub is not "
"search-config v2 shaped"
)
# 2. init() has to actually finish.
if "Completed #init" not in log:
failures.append("SearchService never logged 'Completed #init'")
for line in log.splitlines():
if "#init: failure initializing search" in line:
failures.append(f"SearchService reported init failure: {line.strip()[:160]}")
break
# 3. Settings must reach disk -- SearchSettings refuses to write with no engine.
if not settings_written:
failures.append("search.json.mozlz4 was never written")
# 4. ...and the stance must still hold: nothing but the inert engine.
engines = sorted(set(ADDED_ENGINE_RE.findall(log)))
leaked = [e for e in engines if any(r.lower() in e.lower() for r in REAL_ENGINES)]
if leaked:
failures.append(f"real search engines were added: {', '.join(leaked)}")
if not engines:
failures.append("no engine was added at all (settings cannot be written)")
print(f" recordType errors : {record_type_errors}")
print(f" 'Completed #init' : {'Completed #init' in log}")
print(f" search.json.mozlz4 : {'written' if settings_written else 'MISSING'}"
f"{f' ({len(settings_blob)} bytes)' if settings_written else ''}")
shown = ', '.join(f'"{e}"' for e in engines) if engines else "(none)"
print(f" engines added : {shown}")
print()
if failures:
for f in failures:
print(f"FAIL: {f}")
return 1
print("PASS: the search service initializes, and the only engine is the inert stub.")
return 0
if __name__ == "__main__":
sys.exit(main())
+234
View File
@@ -0,0 +1,234 @@
"""
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 <path> | $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 10, touchscreen) running
# Firefox 152.0, captured with tests/assets/touch-reference.html.
#
# UA Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0)
# Gecko/20100101 Firefox/152.0
# platform Win32 screen 1382x864 @ dpr 2.5
#
# These are the sixteen static touch signals from that capture, verbatim. The
# recording also carries 284 input events and eight context values (screen,
# dpr, hardwareConcurrency, ...) which are not touch signals and are not
# asserted here.
#
# Two of these are easy to get wrong from first principles, so note them:
#
# createTouch is FALSE. document.createTouch is a legacy API gated by
# TouchEvent::LegacyAPIEnabled, the same gate as ontouchstart, and
# dom.w3c_touch_events.legacy_apis.enabled is false off Android. A real
# touchscreen laptop exposes TouchEvent while createTouch stays absent.
# PointerEvent is TRUE, and unconditionally so -- it is not gated on a
# digitizer at all. It is listed to pin that it must not start varying.
# ---------------------------------------------------------------------------
RECORDED: Dict[str, Any] = {
# --- touch API surface ---
"navigator.maxTouchPoints": SPOOFED_TOUCH_POINTS,
"'ontouchstart' in window": False,
"window.TouchEvent": True,
"window.Touch": True,
"document.createTouch": False,
"window.PointerEvent": True,
# --- CSS pointer/hover media queries ---
"(pointer: coarse)": False,
"(pointer: fine)": True,
"(pointer: none)": False,
"(any-pointer: coarse)": True,
"(any-pointer: fine)": True,
"(any-pointer: none)": False,
"(hover: hover)": True,
"(hover: none)": False,
"(any-hover: hover)": True,
"(any-hover: none)": False,
}
# Collected and printed, never asserted on -- none of these is in the recording,
# so there is no measured value to hold them to:
#
# window.TouchList shares TouchEvent's gate, so it tracks TouchEvent and
# flips with it. Worth seeing, but it would just restate that assertion.
# document.createEvent('TouchEvent') gated by LegacyAPIEnabled, like
# createTouch, so it is false on desktop.
# 'ontouchstart' on document / documentElement the same mixin as the window
# one, printed to show all three agree.
INFORMATIONAL = (
"window.TouchList",
"document.createEvent('TouchEvent')",
"'ontouchstart' in document",
"'ontouchstart' in documentElement",
)
# 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; }
let createTouch = false;
try { createTouch = typeof document.createTouch === 'function'; } catch (e) { createTouch = 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,
"document.createTouch": createTouch,
"window.PointerEvent": "PointerEvent" 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()))