harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s

Five defects, one of which destroyed every wallet, came from src/background reading and writing the module-level state singleton the MV3 worker never populates, which silently served DEFAULT_STATE. Each point fix created the next defect. The background now has its own per-call getState() and a queued read-modify-write updateState(); the singleton is unreachable from it, and an unpopulated read throws instead of serving defaults.

The prohibition is enforced by the build, not by review: build.js asserts over esbuild's own metafile that no forbidden module is an input of a background bundle, so every specifier syntax esbuild resolves is covered, and both halves of the table are checked for rot -- a stale key, a stale module, an empty list, or an unlisted entry point under src/background/ all fail the build. The ESLint rule remains as fast local feedback and reads the same shared table. Known bounds are documented where the table lives.

Also closes #320: getProvider() now requires a validated network id, so a cold worker no longer prepares a non-mainnet dApp transaction for mainnet and gets refused by the wallet's own verifier. backgroundRefresh() no longer mutates address objects across a network round trip, the broadcast path takes its endpoint and chain id from one snapshot, and eight test storage stubs now structured-clone on get as the real chrome.storage.local does.

closes #320
This commit was merged in pull request #344.
This commit is contained in:
2026-08-23 17:57:30 +02:00
parent 36bc6bee0e
commit bd0a626e7b
40 changed files with 2959 additions and 653 deletions

View File

@@ -8,6 +8,10 @@
const js = require("@eslint/js");
const globals = require("globals");
const backgroundState = require("./script/lib/eslint/noStateSingletonInBackground");
const {
BACKGROUND_ENTRY_PREFIX,
} = require("./script/lib/forbiddenBundleInputs");
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
// the code feature-detects between them.
@@ -78,12 +82,36 @@ module.exports = [
},
// MV3 background: a service worker, with no window and no document.
//
// It also may not reach src/shared/state.js. That module's `state` export
// is a per-bundle singleton loaded once and mutated in place, which is the
// popup's lifetime and not the worker's: the worker is killed when idle,
// nothing loads state at module scope, and an unpopulated read used to be
// served DEFAULT_STATE silently. Five defects came from background code
// reading or writing it (https://git.eeqj.de/sneak/AutistMask/issues/324),
// and each point fix added a loadState() that created the next one. The
// rule below checks reachability through the whole require graph, not just
// the direct require, because a re-export from any shared module the
// background already pulls in would put the singleton back in the bundle
// with no background file naming it.
//
// It is not the guarantee: build.js asserts the same prohibition against
// esbuild's own metafile, from the shared table in
// script/lib/forbiddenBundleInputs.js. This is the early report.
//
// The glob comes from that same file, because build.js uses the prefix to
// decide which entry points must be listed in the table at all: the two
// layers must not disagree about which files are "the background".
{
files: ["src/background/**/*.js"],
files: [`${BACKGROUND_ENTRY_PREFIX}**/*.js`],
plugins: { background: backgroundState },
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
rules: {
"background/no-state-singleton-in-background": "error",
},
},
// src/shared is bundled into both, so it may only use what both provide:
@@ -107,9 +135,9 @@ module.exports = [
},
},
// Unit tests: jest on node.
// Unit tests, and the helpers they require: jest on node.
{
files: ["tests/**/*.test.js"],
files: ["tests/**/*.test.js", "tests/support/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },