From cb44e9903d5f0d5eb34e90fe368e5ddcf3e81e2b Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 26 Oct 2026 21:38:59 +0000 Subject: [PATCH] fix: AutistMask does not run in Firefox private windows (closes #646) Firefox gives an extension page in a private window locks of its own. A popup there could save between the background's read and its write of the stored record, and undo a network switch the background had saved. manifest/firefox.json now sets "incognito": "not_allowed", so Firefox never runs AutistMask in a private window. tests/manifest.test.js pins the key. The README and five code comments no longer describe a private-window exception to the lock. Model: opus-5-5 --- README.md | 19 ++++++++++--------- manifest/firefox.json | 1 + src/background/state.js | 12 +++++------- src/popup/views/stateRecovery.js | 2 -- src/shared/state.js | 4 +--- tests/manifest.test.js | 9 +++++++++ tests/stateWriteLock.test.js | 5 ++--- tests/support/navigatorLocks.js | 5 ++--- 8 files changed, 30 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2e64716..d9745a0 100644 --- a/README.md +++ b/README.md @@ -1494,15 +1494,16 @@ wrote last, the record says which build's shape it is in. Each write reads the record, changes it and writes it back, holding a lock from the read to the write: the browser's own (`navigator.locks`), taken through `withLock()` in `src/shared/browserApi.js`. Every page of the extension and its -background share it, except a page in a Firefox private window, which Firefox -gives locks of its own (see https://git.eeqj.de/sneak/AutistMask/issues/646). -The popup's `saveState()`, the background's `updateState()` and the -StateRecovery reset all take it, so among the pages and the background that -share it, no write lands between another write's read and its write, where it -would be lost. Chrome has it from version 69, older than any Chrome that runs a -Manifest V3 extension, in pages and in the service worker; Firefox from version -96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a -write fails rather than going ahead without it. +background share it. `manifest/firefox.json` sets `"incognito": "not_allowed"`, +so no page runs in a Firefox private window. Firefox would give a page there +locks of its own. In Chrome, a page opened from an incognito window still runs +outside incognito and shares the lock. The popup's `saveState()`, the +background's `updateState()` and the StateRecovery reset all take it, so no +write lands between another write's read and its write, where it would be lost. +Chrome has it from version 69, older than any Chrome that runs a Manifest V3 +extension, in pages and in the service worker; Firefox from version 96, older +than the 115 `manifest/firefox.json` declares. Where it is missing, a write +fails rather than going ahead without it. Version 1 is the shape that shipped before versions existed, so a stored record with no `schemaVersion` is version 1 rather than a defect: it loads normally and diff --git a/manifest/firefox.json b/manifest/firefox.json index 74b6e6f..08d8c6a 100644 --- a/manifest/firefox.json +++ b/manifest/firefox.json @@ -4,6 +4,7 @@ "version": "0.1.0", "description": "Minimal Ethereum wallet for Firefox", "permissions": ["storage", "activeTab", "alarms", ""], + "incognito": "not_allowed", "content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'", "icons": { "16": "icons/icon16.png", diff --git a/src/background/state.js b/src/background/state.js index 4f29804..3710d37 100644 --- a/src/background/state.js +++ b/src/background/state.js @@ -18,11 +18,10 @@ // updateState(fn) read fresh, apply fn to that fresh record, write it // back — all holding the lock every write of the stored // record takes, so no other write lands in between, by -// the background or by any page except a page in a -// Firefox private window (README, "Stored state and its -// version"). The read is one storage round trip ahead of -// the write rather than a page lifetime ahead of it -// (which is what made the popup's saveState() need a +// the background or by any page (README, "Stored state +// and its version"). The read is one storage round trip +// ahead of the write rather than a page lifetime ahead of +// it (which is what made the popup's saveState() need a // per-field merge against a baseline at all). // // A handler that must both read and write therefore does its network work @@ -73,8 +72,7 @@ async function updateStateOnce(mutate) { // src/shared/browserApi.js) is held from the read to the write: two // background turns, or a turn and a page's save, never interleave // (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits -// while it is held, except a page in a Firefox private window, which Firefox -// gives locks of its own (README, "Stored state and its version"). +// while it is held (README, "Stored state and its version"). // // `mutate` must also not call updateState() itself, directly or through // anything it awaits: the inner turn waits for the lock the outer one holds, diff --git a/src/popup/views/stateRecovery.js b/src/popup/views/stateRecovery.js index 5449fed..0d56f5b 100644 --- a/src/popup/views/stateRecovery.js +++ b/src/popup/views/stateRecovery.js @@ -143,8 +143,6 @@ async function resetProfile() { try { // Under the lock every write of the stored record takes, so the erase // cannot land between another write's read and its write and be undone. - // A page in a Firefox private window is the exception: Firefox gives - // it locks of its own (README, "Stored state and its version"). await withLock("autistmask", () => storageRemove("autistmask")); } catch (e) { log.errorf("state recovery: reset failed:", e); diff --git a/src/shared/state.js b/src/shared/state.js index 407c18f..227763c 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -581,9 +581,7 @@ function reportSaveFailure(err) { // Each save runs start to finish holding the lock every write of the stored // record takes (withLock() in src/shared/browserApi.js), so no write by // another page or the background lands between its read and its write and is -// lost (https://git.eeqj.de/sneak/AutistMask/issues/641). A page in a Firefox -// private window is the exception: Firefox gives it locks of its own (README, -// "Stored state and its version"). +// lost (https://git.eeqj.de/sneak/AutistMask/issues/641). // // The same lock orders this page's own saves. showView() calls saveState() on // every navigation without awaiting it, so two saves from one page can be diff --git a/tests/manifest.test.js b/tests/manifest.test.js index af5cdf0..8a8f8a1 100644 --- a/tests/manifest.test.js +++ b/tests/manifest.test.js @@ -165,6 +165,15 @@ describe("declared icons", () => { }); }); +// Firefox gives a page in a private window locks of its own, so that page's +// save could land between the background's read and its write (README, +// "Stored state and its version"). +describe("private windows", () => { + test("firefox never runs AutistMask in a private window", () => { + expect(readManifest("firefox").incognito).toBe("not_allowed"); + }); +}); + describe("shipped Content Security Policy", () => { // MV3 takes an object and applies extension_pages to the popup and the // background service worker, which is where libsodium runs. diff --git a/tests/stateWriteLock.test.js b/tests/stateWriteLock.test.js index 041950c..3b37f9b 100644 --- a/tests/stateWriteLock.test.js +++ b/tests/stateWriteLock.test.js @@ -6,9 +6,8 @@ // now holds a lock from its read to its write. // // The popup and the background run here as the real modules over one shared -// storage stub, and share the lock the way the background and a page outside -// a Firefox private window share the browser's -// (tests/support/navigatorLocks.js). +// storage stub, and share the lock the way the background and its pages share +// the browser's (tests/support/navigatorLocks.js). const { makeStorageStub } = require("./support/storageStub"); diff --git a/tests/support/navigatorLocks.js b/tests/support/navigatorLocks.js index f24f1b4..4722749 100644 --- a/tests/support/navigatorLocks.js +++ b/tests/support/navigatorLocks.js @@ -4,9 +4,8 @@ // the callback settles, whether it resolved or rejected. That is what the // browser's own does for the one call the extension makes, // `navigator.locks.request(name, callback)`. Every module a test file loads -// shares it, as the extension's background and its pages share the browser's, -// except a page in a Firefox private window (README, "Stored state and its -// version"). +// shares it, as the extension's background and its pages share the browser's +// (README, "Stored state and its version"). // // Run before each test file by `setupFiles` in package.json.