harden: make the background physically unable to read the shared state singleton (closes #324)
All checks were successful
check / check (push) Successful in 54s
e2e / e2e-chrome (push) Successful in 2m0s
e2e / e2e-firefox (push) Successful in 54s

Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.

So the reachability goes rather than a sixth call site.

The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.

- Every handler takes one snapshot and answers from it, including the address
  it names: activeAddressOf(s) replaced a second, later storage read that
  could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
  chainSwitch.js, which keeps the singleton path for the popup) inside
  updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
  around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
  balances that came back by address, so it never publishes an object other
  in-flight work holds, and a wallet added or deleted during the round trip
  survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
  snapshot. They used to come from different moments, so a chain switch
  committed in between moved the endpoint under an artifact already verified
  against the old chain.

getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.

The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's assertNoForbiddenInputs() fails the build when esbuild's
metafile reports src/shared/state.js as an input of a background bundle, naming
the import chain from the metafile's own graph. That is the resolution the
shipped bundle was built from, so no specifier syntax, no hop and no resolution
rule can slip past it; Dockerfile:42 runs make build, so it holds in CI.

Both halves of the table are checked for rot, because either one turns the
prohibition into a pass that checks nothing: a key no bundled entry point
matched fails, and so does a listed module this build bundled nowhere. The
second is what makes a rename of src/shared/state.js loud instead of silently
disarming the check, and it is stronger than an existsSync() because it also
fails when the module is still there but has dropped out of every bundle. What
the assertion does NOT cover is a COPY of the singleton at another path: it is
keyed by path, so a copy builds and lints clean. That is stated where the table
lives, with the reason it is accepted — a copy carries the singleton's own
guard, so a background read of it throws rather than being served DEFAULT_STATE.

make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker.

A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It reads
the same table, and it matches specifiers textually, so it is best-effort fast
feedback and not the guarantee — two earlier revisions of it shipped holes (a
template literal, a dynamic import(), a comment inside the call, a directory
resolved through package.json main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js. Two shapes it does not report are
recorded in its header as known divergences the build catches, both measured: a
computed specifier (require("../shared/" + "state"), which esbuild
constant-folds into the bundle) and a symlink to the module (esbuild reports the
real path). Each is make lint exit 0 and make build exit 2.

Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.

Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.

closes #320
This commit is contained in:
2026-08-23 13:43:07 +00:00
parent 36bc6bee0e
commit 18ad93be45
40 changed files with 2626 additions and 652 deletions

View File

@@ -0,0 +1,204 @@
// ESLint rule: the background bundle may not reach the shared state singleton.
//
// src/shared/state.js holds a module-level `state` object, loaded once by
// loadState() and mutated in place from then on. That is the popup's model. In
// the MV3 service worker there is no "once": the worker is terminated when
// idle and revived by the next message, nothing loads state at module scope,
// and an unpopulated read used to be served DEFAULT_STATE without complaint —
// five defects, one cause
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The background has its
// own per-call storage layer in src/background/state.js instead.
//
// THIS RULE IS NOT THE GUARANTEE, and must not be described as one. The
// guarantee is in build.js: FORBIDDEN_INPUTS / assertNoForbiddenInputs() fails
// the build when esbuild's own metafile reports src/shared/state.js as an input
// of a background bundle. That consults the resolution esbuild actually
// performed, so no specifier syntax and no resolution rule can slip past it,
// and Dockerfile:42 runs `make build` in CI.
//
// What this rule is: fast local feedback, in the editor and in `make lint`,
// before a full bundle. It reads sources from disk and matches import
// specifiers TEXTUALLY, so it is a best-effort approximation of module
// resolution — a hand-rolled matcher will diverge from a real bundler, and two
// earlier revisions of this file proved it by shipping holes (a template
// literal, a dynamic `import()`, a comment inside the call, a directory
// resolved through `package.json` `main`). Those are all covered now, and the
// next divergence is caught by the build rather than by widening this again.
//
// It checks REACHABILITY, not just the direct require: the singleton is one
// `require()` away from any shared module the background pulls in, and a
// re-export would put it back in the bundle without any background file naming
// it. So each background file is the root of a walk over the CommonJS require
// graph, and the error names the whole chain that brought the singleton in.
//
// Matching textually over-approximates — a specifier inside a comment or a
// string counts — which is the safe direction here: the failure mode is a
// spurious error naming an exact file and line, not a silent hole.
//
// Two shapes this rule does NOT report, both of which the build does fail on
// (each measured with `make lint` and `make build` on the branch that added
// this note):
//
// - a computed specifier, `require("../shared/" + "state")` — esbuild
// constant-folds it, so it is in the bundle and `make build` is exit 2
// naming src/shared/state.js, while `make lint` is exit 0. Same for
// `import("../shared/" + variable)`, which esbuild resolves as a glob.
// - a symlink to the module — esbuild reports the real path and fails the
// build; this rule resolves the link's own path and sees a different file.
//
// They are listed as known divergences, not as things that cannot happen. A
// matcher will keep diverging from a bundler; that is why the guarantee is the
// build's and this rule is not widened again to chase them.
const fs = require("fs");
const path = require("path");
const { FORBIDDEN_INPUTS } = require("../forbiddenBundleInputs");
// The modules to keep out, repo-relative, taken from the same table build.js
// asserts against so that the two layers cannot name different paths. A second
// literal copy here is how a rename disarms one of them while the other still
// looks enforced.
const FORBIDDEN = [...new Set(Object.values(FORBIDDEN_INPUTS).flat())];
// Whatever may sit between a keyword, a paren and a specifier: whitespace and
// comments. `import(/* webpackChunkName: "x" */ "./x")` is a standard bundler
// idiom, and an inline `/* eslint-… */` is just as ordinary, so a matcher that
// allows only \s there is not strict, it is broken. Each alternative starts
// with a distinct character, so this cannot backtrack quadratically.
const GAP = "(?:\\s|/\\*[^]*?\\*/|//[^\\n]*)";
const SPECIFIER = "[\"'`]([^\"'`]+)[\"'`]";
// Both alternatives capture the specifier: call form first
// (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare
// side-effect `import "x"`). Nothing after the specifier is matched, so a
// trailing comment or a trailing comma cannot break the match either.
const SPECIFIER_RE = new RegExp(
`\\b(?:require|import)${GAP}*\\(${GAP}*${SPECIFIER}` +
`|\\b(?:from|import)${GAP}+${SPECIFIER}`,
"g",
);
// The `main` of a directory's package.json, as a specifier relative to that
// directory, or null. esbuild resolves a directory through it, so a walk that
// stops at `<dir>/index.js` reports a specifier it matched perfectly well as
// unresolvable.
function packageMain(dir) {
try {
const pkg = JSON.parse(
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
);
return typeof pkg.main === "string" && pkg.main ? pkg.main : null;
} catch {
return null;
}
}
// Resolve a relative require to a file path, trying what node and esbuild would
// in the order they would: the path itself, then extensions, then the directory
// (its package.json `main`, then its index.js).
function resolveRelative(fromFile, spec) {
if (!spec.startsWith(".")) return null; // a package, not our tree
const base = path.resolve(path.dirname(fromFile), spec);
const main = packageMain(base);
for (const candidate of [
base,
base + ".js",
base + ".json",
...(main
? [path.resolve(base, main), path.resolve(base, main) + ".js"]
: []),
path.join(base, "index.js"),
]) {
try {
if (fs.statSync(candidate).isFile()) return candidate;
} catch {
// Not this candidate.
}
}
return null;
}
function requiresOf(file) {
let source;
try {
source = fs.readFileSync(file, "utf8");
} catch {
return [];
}
const out = [];
for (const match of source.matchAll(SPECIFIER_RE)) {
const resolved = resolveRelative(file, match[1] ?? match[2]);
if (resolved) out.push(resolved);
}
return out;
}
// Breadth-first from `entry`, returning the shortest chain of files that ends
// at one of the forbidden modules, or null when none is reachable.
function chainToForbidden(entry, forbidden) {
const seen = new Set([entry]);
const queue = [[entry]];
while (queue.length > 0) {
const chain = queue.shift();
for (const next of requiresOf(chain[chain.length - 1])) {
if (forbidden.has(next)) return chain.concat([next]);
if (seen.has(next)) continue;
seen.add(next);
queue.push(chain.concat([next]));
}
}
return null;
}
const rule = {
meta: {
type: "problem",
docs: {
description:
"the background bundle must not be able to reach the" +
" module-level state singleton in src/shared/state.js",
},
schema: [],
messages: {
reachable:
"The background must not reach the shared state singleton:" +
" {{chain}}. The MV3 worker never populates it, so reading it" +
" serves DEFAULT_STATE. Use getState()/updateState() from" +
" src/background/state.js instead.",
},
},
create(context) {
return {
"Program:exit"(node) {
const filename = context.filename;
// ESLint lints from the repo root, which is also where the
// forbidden paths are anchored.
const forbidden = new Set(
FORBIDDEN.map((module) =>
path.resolve(context.cwd, module),
),
);
const chain = chainToForbidden(
path.resolve(filename),
forbidden,
);
if (!chain) return;
context.report({
node,
messageId: "reachable",
data: {
chain: chain
.map((file) => path.relative(context.cwd, file))
.join(" -> "),
},
});
},
};
},
};
module.exports = {
rules: { "no-state-singleton-in-background": rule },
};

View File

@@ -0,0 +1,54 @@
// The modules a given entry point's bundle may not contain, keyed by the
// repo-relative entry point.
//
// ONE table, read by both layers that act on it: build.js asserts it against
// esbuild's own metafile (the guarantee), and
// script/lib/eslint/noStateSingletonInBackground.js reports the same
// prohibition in the editor (fast feedback). It lives here because a second
// literal copy of the path is exactly how a rename disarms one layer while the
// other still looks enforced.
//
// src/shared/state.js holds a module-level `state` object, loaded once by
// loadState() and mutated in place from then on. That is the popup's model:
// one page, one load at boot, one lifetime. The MV3 service worker has no
// "once" — it is killed when idle and revived by the next message, nothing
// loads state at module scope, and an unpopulated read was answered out of
// DEFAULT_STATE in silence. Five defects came from that, one of which
// destroyed a wallet (https://git.eeqj.de/sneak/AutistMask/issues/324). The
// background has its own per-call storage layer in src/background/state.js
// instead.
//
// What build.js's assertion covers, measured rather than assumed:
//
// - Any import of a listed module, at any hop, in any specifier syntax,
// however esbuild resolved it. The check reads the input list esbuild
// reported for the emitted bundle, so it is the resolution the shipped
// file was built from and not a model of it. Measured on a computed
// specifier that esbuild constant-folds (`require("../shared/" +
// "state")`), on a computed specifier it resolves as a glob
// (`import("../shared/" + variable)`), and on a symlink to the module
// (esbuild reports the real path): each is `make build` exit 2.
//
// - NOT covered: a COPY of a listed module at another path. The table is
// keyed by path, so `cp src/shared/state.js src/shared/stateCopy.js` plus
// a background require of the copy is `make build` exit 0 and `make lint`
// exit 0 (measured). That is deliberate rather than an oversight: a copy
// carries the singleton's own guard, so a background read of an unloaded
// field throws StateNotLoadedError instead of being served DEFAULT_STATE
// — loud, which is the opposite of the failure this table exists to
// prevent. A newly WRITTEN singleton would carry no such backstop, and
// nothing mechanical catches that one.
//
// - The table protects the entry points it names. A second background-side
// entry point added later needs its own line here; the ESLint rule's
// src/background/** glob would cover it, the build assertion would not.
//
// Both halves are checked for rot at the end of a build: a key no bundled
// entry point matched, and a listed module this build bundled nowhere, each
// fail the build rather than passing vacuously
// (assertForbiddenTableCovered(), pinned by tests/buildForbiddenInputs.test.js).
const FORBIDDEN_INPUTS = {
"src/background/index.js": ["src/shared/state.js"],
};
module.exports = { FORBIDDEN_INPUTS };