harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
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:
123
script/lib/forbiddenBundleInputs.js
Normal file
123
script/lib/forbiddenBundleInputs.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// 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.
|
||||
//
|
||||
// - Every background entry point, whether or not anyone remembered to list
|
||||
// it. A bundled entry point under BACKGROUND_ENTRY_PREFIX with no line in
|
||||
// this table fails the build (assertNoForbiddenInputs()), so adding a
|
||||
// second worker entry point is protected by default rather than protected
|
||||
// only if the person adding it knew about this file. Measured: bundling
|
||||
// src/background/worker2.js with no line here 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). The copy carries the singleton's own guard, so
|
||||
// defects 1-3 of https://git.eeqj.de/sneak/AutistMask/issues/324 — a read
|
||||
// of a field nothing loaded — become a loud StateNotLoadedError instead of
|
||||
// a silent DEFAULT_STATE. Defects 4 and 5 do NOT: a copy also carries
|
||||
// loadState(), and a stale read several awaits after a load, or a load
|
||||
// detaching the objects an in-flight handler is mutating, are silent over
|
||||
// a LOADED singleton whether it is the original or a copy. So the residual
|
||||
// is wider than "it fails loudly". A newly WRITTEN singleton has no
|
||||
// backstop at all.
|
||||
//
|
||||
// - NOT covered: a background-behaving entry point outside
|
||||
// BACKGROUND_ENTRY_PREFIX. The default protection above is keyed on that
|
||||
// directory, which is also what eslint.config.js scopes the rule to, so a
|
||||
// worker entry point placed somewhere else is covered by neither layer and
|
||||
// needs its own line here.
|
||||
//
|
||||
// The ESLint rule's bounds are its own and are narrower: it matches specifiers
|
||||
// textually, so a computed specifier and a symlink to a listed module are
|
||||
// reported by the build and not by the rule. Both are pinned as non-reports in
|
||||
// tests/backgroundStateLintRule.test.js and are `make build` exit 2 (measured).
|
||||
// A second background entry point reached by one of those two shapes is
|
||||
// therefore caught by the build and not by the rule — which is the same
|
||||
// division of labour as everywhere else here, not an extra hole.
|
||||
//
|
||||
// Every way the table itself can rot is a failure rather than a quiet pass:
|
||||
//
|
||||
// - a KEY no bundled entry point matched, and a listed MODULE this build
|
||||
// bundled nowhere: assertForbiddenTableCovered(), at the end of a build;
|
||||
// - an entry that lists NO modules, and a table with no entries at all:
|
||||
// assertTableWellFormed() below, at require time — so it fails the build
|
||||
// and the lint run alike, because the rule reads the same values and an
|
||||
// empty list leaves it with nothing to look for.
|
||||
//
|
||||
// All of it is pinned by tests/buildForbiddenInputs.test.js.
|
||||
|
||||
// What counts as a background entry point, and therefore must be listed above.
|
||||
// The build has no other notion of one: entry points are the paths handed to
|
||||
// bundle(), and this prefix is the narrowest rule that names the worker's
|
||||
// directory. eslint.config.js scopes the lint rule with the same prefix, from
|
||||
// this constant, so the two layers cannot disagree about what "background"
|
||||
// means.
|
||||
const BACKGROUND_ENTRY_PREFIX = "src/background/";
|
||||
|
||||
const FORBIDDEN_INPUTS = {
|
||||
"src/background/index.js": ["src/shared/state.js"],
|
||||
};
|
||||
|
||||
// Refuse a table that cannot prohibit anything. An entry whose module list is
|
||||
// empty passes every check in both layers while enforcing nothing: the build
|
||||
// finds no module to look for and records the entry as checked, and the rule's
|
||||
// forbidden set — Object.values(...).flat() — comes back empty, so a plain
|
||||
// `require("../shared/state")` in the worker is green everywhere. That is a
|
||||
// one-character edit, so it fails here, where the table is defined and both
|
||||
// layers must load it, rather than in either layer's own checks.
|
||||
function assertTableWellFormed(table) {
|
||||
const entries = Object.entries(table);
|
||||
if (entries.length === 0) {
|
||||
throw new Error(
|
||||
"FORBIDDEN_INPUTS is empty, so nothing is prohibited anywhere. " +
|
||||
"Removing the last entry disables the guarantee behind " +
|
||||
"https://git.eeqj.de/sneak/AutistMask/issues/324.",
|
||||
);
|
||||
}
|
||||
for (const [entry, modules] of entries) {
|
||||
if (!Array.isArray(modules) || modules.length === 0) {
|
||||
throw new Error(
|
||||
`FORBIDDEN_INPUTS["${entry}"] lists no modules, so it ` +
|
||||
`prohibits nothing while still looking enforced. Give it ` +
|
||||
`the modules that entry point may not reach, or remove ` +
|
||||
`the entry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTableWellFormed(FORBIDDEN_INPUTS);
|
||||
|
||||
module.exports = {
|
||||
BACKGROUND_ENTRY_PREFIX,
|
||||
FORBIDDEN_INPUTS,
|
||||
assertTableWellFormed,
|
||||
};
|
||||
Reference in New Issue
Block a user