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:
217
build.js
217
build.js
@@ -4,6 +4,11 @@ const crypto = require("crypto");
|
||||
const { execSync } = require("child_process");
|
||||
const esbuild = require("esbuild");
|
||||
const { resolveVersion } = require("./script/lib/version");
|
||||
const {
|
||||
BACKGROUND_ENTRY_PREFIX,
|
||||
FORBIDDEN_INPUTS,
|
||||
assertTableWellFormed,
|
||||
} = require("./script/lib/forbiddenBundleInputs");
|
||||
|
||||
const DIST = path.join(__dirname, "dist");
|
||||
const DIST_CHROME = path.join(DIST, "chrome");
|
||||
@@ -16,6 +21,18 @@ const SRC = path.join(__dirname, "src");
|
||||
// rotting with it.
|
||||
const AUDITED_MODULE = "src/shared/constants.js";
|
||||
|
||||
// FORBIDDEN_INPUTS — what each entry point's bundle may not contain, and what
|
||||
// that covers — lives in script/lib/forbiddenBundleInputs.js, because the
|
||||
// ESLint rule reads the same table and two literal copies of a path drift.
|
||||
//
|
||||
// This is the authoritative check, and it is here rather than in the linter
|
||||
// because it consults the resolution esbuild actually performed. Any specifier
|
||||
// syntax, any hop, any resolution rule that puts the module in the bundle fails
|
||||
// the build, whether or not a text matcher would have recognized it. A
|
||||
// background entry point the table does not name fails as well, so a second
|
||||
// worker is protected by default rather than by someone remembering this file.
|
||||
// Dockerfile:42 runs `make build`, so it is enforced in CI.
|
||||
|
||||
// The build receipt: every file this build emits, with its sha256 and whether
|
||||
// it is one of the audited bundles. script/verify-build is handed this and
|
||||
// checks dist/ against it, so the file list comes from the build that just ran
|
||||
@@ -65,6 +82,164 @@ function outputsContainingAuditedModule(metafile) {
|
||||
.map(([outFile]) => repoRelative(outFile));
|
||||
}
|
||||
|
||||
// Shortest import chain from `entryInput` to `target` through the metafile's
|
||||
// own input graph, or null when there is none. The message this feeds is the
|
||||
// point of the check: "state.js is in the worker bundle" is not actionable on
|
||||
// its own, "index.js -> chainSwitchFields.js -> state.js" is.
|
||||
function importChain(metafile, entryInput, target) {
|
||||
const graph = new Map(
|
||||
Object.entries(metafile.inputs).map(([input, info]) => [
|
||||
repoRelative(input),
|
||||
(info.imports || []).map((i) => repoRelative(i.path)),
|
||||
]),
|
||||
);
|
||||
const start = repoRelative(entryInput);
|
||||
const seen = new Set([start]);
|
||||
const queue = [[start]];
|
||||
while (queue.length > 0) {
|
||||
const chain = queue.shift();
|
||||
for (const next of graph.get(chain[chain.length - 1]) || []) {
|
||||
if (next === target) return chain.concat([next]);
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
queue.push(chain.concat([next]));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// What the forbidden-input checks accumulate over a whole build: which
|
||||
// FORBIDDEN_INPUTS keys were actually bundled, and every input of every output
|
||||
// this build emitted. Both are read by assertForbiddenTableCovered() at the
|
||||
// end — a table entry naming something that is not there any more enforces
|
||||
// nothing, and must fail rather than pass quietly.
|
||||
function newForbiddenRecord() {
|
||||
return { entriesChecked: new Set(), bundledInputs: new Set() };
|
||||
}
|
||||
|
||||
// Note every input of every output of one esbuild run. Deliberately not
|
||||
// restricted to the entry points named in FORBIDDEN_INPUTS: it is the POPUP
|
||||
// that legitimately bundles src/shared/state.js, and that is what makes
|
||||
// "the forbidden module still exists at this path" checkable at all.
|
||||
function recordBundledInputs(metafile, record) {
|
||||
for (const info of Object.values(metafile.outputs)) {
|
||||
for (const input of Object.keys(info.inputs)) {
|
||||
record.bundledInputs.add(repoRelative(input));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fail the build when an entry point's bundle contains a module it is
|
||||
// prohibited from reaching. The inputs come from esbuild's metafile, so this is
|
||||
// the resolution the shipped bundle was built from and not a guess at it.
|
||||
//
|
||||
// A background entry point with no line in the table fails here too. The five
|
||||
// defects this exists to prevent were accidents, and so is adding a second
|
||||
// worker entry point without knowing that a table somewhere needs a line: the
|
||||
// protection has to be the default for that directory rather than something
|
||||
// the next author must opt into.
|
||||
function assertNoForbiddenInputs(
|
||||
entryPoint,
|
||||
outfile,
|
||||
metafile,
|
||||
record,
|
||||
table = FORBIDDEN_INPUTS,
|
||||
) {
|
||||
const entry = repoRelative(entryPoint);
|
||||
const forbidden = table[entry];
|
||||
if (!forbidden) {
|
||||
if (!entry.startsWith(BACKGROUND_ENTRY_PREFIX)) return;
|
||||
throw new Error(
|
||||
`${entry} is a background entry point with no line in ` +
|
||||
`FORBIDDEN_INPUTS, so nothing stops its bundle from ` +
|
||||
`containing the shared state singleton. Add it to ` +
|
||||
`script/lib/forbiddenBundleInputs.js. The MV3 worker never ` +
|
||||
`populates that singleton, so reading it serves ` +
|
||||
`DEFAULT_STATE; use getState()/updateState() from ` +
|
||||
`src/background/state.js instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
const out = repoRelative(outfile);
|
||||
const entryOutput = Object.entries(metafile.outputs).find(
|
||||
([outFile]) => repoRelative(outFile) === out,
|
||||
);
|
||||
if (!entryOutput) {
|
||||
throw new Error(`esbuild reported no metafile output for ${out}`);
|
||||
}
|
||||
const inputs = new Set(
|
||||
Object.keys(entryOutput[1].inputs).map(repoRelative),
|
||||
);
|
||||
|
||||
// Recorded only once the bundle's inputs are actually in hand. Marking the
|
||||
// entry checked any earlier — as this did — means an early return above
|
||||
// satisfies assertForbiddenTableCovered() with a bundle nobody examined,
|
||||
// and the coverage half cannot tell that from a real check. The lookup
|
||||
// above is the fragile step: repoRelative() resolves against process.cwd()
|
||||
// while esbuild's output keys are cwd-relative, so a change to where the
|
||||
// build runs from could miss.
|
||||
record.entriesChecked.add(entry);
|
||||
|
||||
for (const module of forbidden) {
|
||||
if (!inputs.has(module)) continue;
|
||||
const chain = importChain(metafile, entryPoint, module);
|
||||
throw new Error(
|
||||
`${out} bundles ${module}, which ${entry} must not reach` +
|
||||
`${chain ? `: ${chain.join(" -> ")}` : ""}. The MV3 worker ` +
|
||||
`never populates the shared state singleton, so reading it ` +
|
||||
`serves DEFAULT_STATE. Use getState()/updateState() from ` +
|
||||
`src/background/state.js instead.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fail the build when the table has rotted away from the tree it describes.
|
||||
// Both halves of an entry rot independently, and either one turns the whole
|
||||
// prohibition into a pass that checks nothing:
|
||||
//
|
||||
// - the KEY, when no bundled entry point matches it: the entry point was
|
||||
// renamed or is no longer built, and no bundle was ever tested against the
|
||||
// list;
|
||||
// - the MODULE, when this build bundled it nowhere: the module was renamed,
|
||||
// moved or deleted, so "is it an input of the background bundle" is asked
|
||||
// about a path nothing resolves to and is answered no forever. The popup
|
||||
// legitimately bundles src/shared/state.js, which is what makes this
|
||||
// checkable — and it is stronger than an existsSync(), because it also
|
||||
// fails when the file is still there but has dropped out of every bundle.
|
||||
//
|
||||
// This matters concretely: https://git.eeqj.de/sneak/AutistMask/issues/311
|
||||
// rewrites this persistence layer, and a rename that quietly disarmed the
|
||||
// guarantee would put the singleton back within reach of the worker with every
|
||||
// check in the repo still green.
|
||||
//
|
||||
// The third way — an entry that lists no modules at all — is refused where the
|
||||
// table is defined, at require time, because that one also empties the ESLint
|
||||
// rule's forbidden set and so has to fail before either layer runs. It is
|
||||
// re-checked here so the build's own half does not depend on the table having
|
||||
// been loaded from that file.
|
||||
function assertForbiddenTableCovered(record, table = FORBIDDEN_INPUTS) {
|
||||
assertTableWellFormed(table);
|
||||
for (const [entry, modules] of Object.entries(table)) {
|
||||
if (!record.entriesChecked.has(entry)) {
|
||||
throw new Error(
|
||||
`${entry} is listed in FORBIDDEN_INPUTS but was not bundled, ` +
|
||||
`so nothing checked it`,
|
||||
);
|
||||
}
|
||||
for (const module of modules) {
|
||||
if (record.bundledInputs.has(module)) continue;
|
||||
throw new Error(
|
||||
`${module} is listed in FORBIDDEN_INPUTS for ${entry}, but ` +
|
||||
`this build bundled it nowhere, so the prohibition names ` +
|
||||
`a module that is not in this tree at that path and ` +
|
||||
`nothing enforces it. If the module moved, move it in ` +
|
||||
`script/lib/forbiddenBundleInputs.js too, which both ` +
|
||||
`this check and the ESLint rule read.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every file this build writes under dist/, recorded as it is written. This is
|
||||
// the build's own account of what it emitted; it is never recovered by
|
||||
// listing dist/, because a file that is in dist/ without this build having put
|
||||
@@ -252,6 +427,9 @@ async function build() {
|
||||
// esbuild run below and recorded in the receipt for script/verify-build.
|
||||
const auditedBundles = [];
|
||||
|
||||
// What the forbidden-input checks accumulate across those same runs.
|
||||
const forbiddenRecord = newForbiddenRecord();
|
||||
|
||||
// compile tailwind CSS
|
||||
console.log("Compiling Tailwind CSS...");
|
||||
const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
|
||||
@@ -293,6 +471,15 @@ async function build() {
|
||||
metafile: true,
|
||||
define,
|
||||
});
|
||||
// Before the output is recorded as emitted: a bundle that violates a
|
||||
// prohibition must abort the build, not be written into a receipt.
|
||||
recordBundledInputs(result.metafile, forbiddenRecord);
|
||||
assertNoForbiddenInputs(
|
||||
entryPoint,
|
||||
outfile,
|
||||
result.metafile,
|
||||
forbiddenRecord,
|
||||
);
|
||||
recordEmitted(outfile);
|
||||
auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
|
||||
}
|
||||
@@ -349,6 +536,8 @@ async function build() {
|
||||
path.join(DIST_FIREFOX, "manifest.json"),
|
||||
);
|
||||
|
||||
assertForbiddenTableCovered(forbiddenRecord);
|
||||
|
||||
// Written last so a build that died partway through leaves no receipt at
|
||||
// all, which script/verify-build treats as a hard failure rather than as
|
||||
// "nothing to check".
|
||||
@@ -359,7 +548,27 @@ async function build() {
|
||||
console.log("Build complete: dist/chrome/ and dist/firefox/");
|
||||
}
|
||||
|
||||
build().catch((err) => {
|
||||
console.error(`Build failed: ${err && err.message ? err.message : err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
// Run only as a program. Required as a module — which is how
|
||||
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
|
||||
// builds nothing and writes nothing.
|
||||
if (require.main === module) {
|
||||
build().catch((err) => {
|
||||
console.error(
|
||||
`Build failed: ${err && err.message ? err.message : err}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these
|
||||
// three functions enforce is the guarantee behind
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not
|
||||
// run `make build` — so they are unit tested against synthetic metafiles
|
||||
// rather than being exercised only by CI, where "it ran" is not "it works".
|
||||
module.exports = {
|
||||
importChain,
|
||||
newForbiddenRecord,
|
||||
recordBundledInputs,
|
||||
assertNoForbiddenInputs,
|
||||
assertForbiddenTableCovered,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user