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:
206
script/lib/eslint/noStateSingletonInBackground.js
Normal file
206
script/lib/eslint/noStateSingletonInBackground.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// 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.
|
||||
//
|
||||
// Both are pinned as non-reports in tests/backgroundStateLintRule.test.js, so
|
||||
// this list is a measured description of the rule rather than a claim about
|
||||
// it. They are known divergences, not 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 },
|
||||
};
|
||||
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