Compare commits

..

1 Commits

Author SHA1 Message Date
277ec8c8f8 harden: make the background physically unable to read the shared state singleton (closes #324)
All checks were successful
check / check (push) Successful in 50s
e2e / e2e-chrome (push) Successful in 1m25s
e2e / e2e-firefox (push) Successful in 38s
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: a custom ESLint rule
walks the CommonJS require graph from every src/background/ file and fails the
lint when src/shared/state.js is reachable, naming the chain. A re-export from
any shared module cannot put the singleton back in the bundle unnoticed.

The rule's matcher covers every specifier syntax esbuild resolves statically —
quoted require, backtick require, dynamic import(), and a static import/export
`from` clause — because a narrower match is not a matter of tidiness but a sixth
site the build cannot see: each of those shapes was measured to put state.js in
dist/chrome/src/background/index.js while the lint stayed clean.
tests/backgroundStateLintRule.test.js pins all of them, plus the two-hop
re-export, against a real fixture tree. A computed specifier
(require("../shared/" + "state")) is deliberately not matched: esbuild cannot
resolve it either, so it never reaches the bundle.

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
2026-08-23 14:08:14 +00:00
6 changed files with 259 additions and 88 deletions

16
TODO.md
View File

@@ -64,12 +64,16 @@ but the review is broader than any of them.
REQUIRES the network id, which is what closes
[#320](https://git.eeqj.de/sneak/AutistMask/issues/320) at the shape rather
than at the call site. The prohibition is enforced by an ESLint rule that
walks the background's require graph, so a re-export cannot put the singleton
back in the bundle, and reading an unloaded singleton now throws
`StateNotLoadedError` instead of serving defaults. The `chrome.storage.local`
stubs in eight test files aliased instead of structured-cloning, which could
let an assertion pass on a build that never wrote anything; they all go
through `tests/support/storageStub.js` now.
walks the background's require graph and matches every specifier syntax
esbuild resolves — quoted, backtick, dynamic `import()` and `from` clause — so
neither a re-export nor an unusual specifier can put the singleton back in the
bundle; the rule's own coverage is pinned by
`tests/backgroundStateLintRule.test.js`. Reading an unloaded singleton now
throws `StateNotLoadedError` instead of serving defaults. The
`chrome.storage.local` stubs in eight test files aliased instead of
structured-cloning, which could let an assertion pass on a build that never
wrote anything; every test that drives real persistence now goes through
`tests/support/storageStub.js`.
- 2026-08-23: A failed release build no longer leaves a loadable debug bundle in
`dist/` ([#333](https://git.eeqj.de/sneak/AutistMask/issues/333)). With
`AUTISTMASK_DEBUG=1` exported, `make build` compiled a debug bundle and failed

View File

@@ -17,10 +17,23 @@
// the root of a walk over the CommonJS require graph, and the error names the
// whole chain that brought the singleton in.
//
// The walk reads sources from disk and matches `require("...")` textually.
// That over-approximates — a require inside a comment or a string counts — and
// over-approximating is the safe direction for a prohibition: the failure mode
// is a spurious error naming an exact file and line, not a silent hole.
// The walk reads sources from disk and matches import specifiers textually.
// That over-approximates — a specifier inside a comment or a string counts —
// and over-approximating is the safe direction for a prohibition: the failure
// mode is a spurious error naming an exact file and line, not a silent hole.
//
// It has to match EVERY specifier syntax esbuild resolves statically, because
// the hole a narrower match leaves is not "the rule is less tidy", it is a
// sixth site the build cannot see. Matching only `require("x")` and `require('x')`
// let four shapes through, each of which was confirmed to put the singleton in
// the shipped worker bundle: a backtick `require(`x`)`, a dynamic `import("x")`,
// a static `import ... from "x"` / `export ... from "x"`, and any of those one
// hop away in a shared module the background already pulls in.
//
// Known and deliberate gap: a computed specifier, `require("../shared/" +
// "state")`. It is not matched here, and it is not a hole — esbuild cannot
// resolve it statically either, so it never reaches the bundle. Contorting the
// rule to chase it would buy nothing.
const fs = require("fs");
const path = require("path");
@@ -28,7 +41,11 @@ const path = require("path");
// The module this rule exists to keep out, relative to the repo root.
const FORBIDDEN = path.join("src", "shared", "state.js");
const REQUIRE_RE = /\brequire\(\s*["']([^"']+)["']\s*\)/g;
// Both alternatives capture the specifier: call form first
// (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare
// side-effect `import "x"`).
const SPECIFIER_RE =
/\b(?:require|import)\(\s*["'`]([^"'`]+)["'`]\s*\)|\b(?:from|import)\s+["'`]([^"'`]+)["'`]/g;
// Resolve a relative require to a file path, trying the extensions node would.
function resolveRelative(fromFile, spec) {
@@ -57,8 +74,8 @@ function requiresOf(file) {
return [];
}
const out = [];
for (const match of source.matchAll(REQUIRE_RE)) {
const resolved = resolveRelative(file, match[1]);
for (const match of source.matchAll(SPECIFIER_RE)) {
const resolved = resolveRelative(file, match[1] ?? match[2]);
if (resolved) out.push(resolved);
}
return out;

View File

@@ -59,6 +59,11 @@ async function updateStateOnce(mutate) {
// write is lost, and keeping it to one storage round trip is what makes a
// whole-record write safe here.
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the queue is strictly serial, so the inner turn waits on
// the outer one, which is waiting on the inner one. That deadlocks the whole
// background, not just the caller. Mutate the record you were handed.
//
// Resolves with the record that was written.
function updateState(mutate) {
const turn = updateQueue.then(() => updateStateOnce(mutate));

View File

@@ -0,0 +1,191 @@
// The lint rule that keeps src/shared/state.js out of the background bundle
// (script/lib/eslint/noStateSingletonInBackground.js).
//
// Five defects, one of which destroyed a wallet, came from background code
// reaching that singleton, and each point fix created the next site
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The prohibition is
// therefore mechanical rather than a review item — which means the rule's
// coverage is itself load-bearing, and a hole in it is indistinguishable from
// having no rule at all.
//
// The hole this file exists to pin shut is SPECIFIER SYNTAX. The rule walks the
// require graph textually, and a first version matched only `require("x")` and
// `require('x')`. Every shape below was measured against a real `make build`:
// each one puts state.js in dist/chrome/src/background/index.js, and each one
// was invisible to the narrower match. So each is a case here, and a regression
// in the matcher fails the suite instead of shipping a sixth site.
//
// NOT covered, deliberately: a computed specifier such as
// `require("../shared/" + "state")`. esbuild cannot resolve that statically
// either, so it never reaches the bundle — there is nothing to block.
const fs = require("fs");
const os = require("os");
const path = require("path");
const { Linter } = require("eslint");
const plugin = require("../script/lib/eslint/noStateSingletonInBackground");
const RULE = "background/no-state-singleton-in-background";
// The three files a fixture tree always has. `src/background/index.js` is
// supplied per case; the other two stand in for the real modules.
const SHARED_STATE = "const state = {};\nmodule.exports = { state };\n";
const SHARED_HOP =
"// A shared module the background legitimately imports.\n" +
"module.exports = { applyChainSwitchFields() {} };\n";
let roots = [];
function fixture(files) {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-state-rule-")),
);
roots.push(root);
const tree = {
"src/shared/state.js": SHARED_STATE,
"src/shared/chainSwitchFields.js": SHARED_HOP,
...files,
};
for (const [rel, source] of Object.entries(tree)) {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, source);
}
return root;
}
// Run the rule exactly as eslint.config.js runs it, over a real tree: the walk
// reads its sources from disk, so a virtual RuleTester would not exercise it.
// `sourceType` is the fixture's own, not the rule's business: the walk is
// textual and never parses the files it follows. The two ESM cases below pass
// "module" only so espree can parse the fixture at all — in this repo those
// shapes are also a parse error under the commonjs config, but the rule must
// not be left depending on that.
function lintBackground(root, { sourceType = "commonjs" } = {}) {
const file = path.join(root, "src/background/index.js");
const linter = new Linter({ cwd: root });
return linter.verify(
fs.readFileSync(file, "utf8"),
{
plugins: { background: plugin },
languageOptions: { ecmaVersion: 2024, sourceType },
rules: { [RULE]: "error" },
},
file,
);
}
function chainOf(messages) {
expect(messages).toHaveLength(1);
expect(messages[0].ruleId).toBe(RULE);
// "...singleton: <chain>. The MV3 worker..." — the chain is what the
// message exists to hand the reader, so assert on it rather than on the
// fact that something was reported.
return messages[0].message.split("singleton: ")[1].split(". The MV3")[0];
}
afterEach(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
describe("every specifier syntax esbuild resolves is blocked", () => {
test("a quoted require", () => {
const root = fixture({
"src/background/index.js":
'const { state } = require("../shared/state");\n' +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a backtick require", () => {
const root = fixture({
"src/background/index.js":
"const { state } = require(`../shared/state`);\n" +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a dynamic import inside an async function", () => {
const root = fixture({
"src/background/index.js":
"async function readState() {\n" +
' const m = await import("../shared/state");\n' +
" return m.state;\n" +
"}\n" +
"module.exports = { readState };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a static import from-clause", () => {
const root = fixture({
"src/background/index.js":
'import { state } from "../shared/state";\n' +
"export { state };\n",
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a bare side-effect import", () => {
const root = fixture({
"src/background/index.js": 'import "../shared/state";\n',
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
});
describe("reachability, not just the direct specifier", () => {
// The shape a no-restricted-imports could never see: no background file
// names state.js, and the singleton is in the bundle anyway. In a backtick
// require, so this fails on the specifier widening as well as on the walk.
test("a two-hop re-export through a shared module", () => {
const root = fixture({
"src/background/index.js":
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { applyChainSwitchFields };\n",
"src/shared/chainSwitchFields.js":
SHARED_HOP +
"module.exports.state = require(`./state`).state;\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/chainSwitchFields.js" +
" -> src/shared/state.js",
);
});
});
describe("what the rule must not report", () => {
test("a background file that reaches only its own state layer", () => {
const root = fixture({
"src/background/index.js":
'const { getState } = require("./state");\n' +
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { getState, applyChainSwitchFields };\n",
"src/background/state.js":
"async function getState() {}\nmodule.exports = { getState };\n",
});
expect(lintBackground(root)).toEqual([]);
});
// The tree as it actually stands. This is the assertion that would catch a
// widened matcher that resolves something it should not: it runs the rule
// over the real background entrypoint, from the real repo root.
test("the repository's own background entrypoint", () => {
const root = path.resolve(__dirname, "..");
expect(lintBackground(root)).toEqual([]);
});
});

View File

@@ -13,13 +13,15 @@
// never persisting it looks identical from `state`, and a build that never
// wrote at all would pass a check that only reads `state` back.
//
// That makes the storage stub load-bearing, so it is a real store that
// structured-clones on both `set` and `get`. A stub whose `get` hands back
// the same object its `set` was given aliases the caller's own array: the
// test then reads its own in-memory mutation and calls it persistence, and
// passes against a build that persists nothing (see issue #324). The
// aliasing is closed off explicitly by the first test below rather than
// left as an assumption about `structuredClone`.
// That makes the storage stub load-bearing, so it is the shared one from
// tests/support/storageStub.js, a real store that structured-clones on both
// `set` and `get`. A stub whose `get` hands back the same object its `set`
// was given aliases the caller's own array: the test then reads its own
// in-memory mutation and calls it persistence, and passes against a build
// that persists nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The aliasing is closed
// off explicitly by the first test below rather than left as an assumption
// about `structuredClone`.
//
// The view is driven against a minimal DOM stub, in the same shape as
// tests/exportPrivkey.test.js: the module reads and writes named nodes and
@@ -34,6 +36,7 @@ jest.mock("../src/shared/vault", () => ({
}));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const { makeStorageStub } = require("./support/storageStub");
const VIEW = "delete-wallet-lost-password";
@@ -94,35 +97,6 @@ function makeDocument() {
};
}
// --------------------------------------------------------- storage stub
// A store that behaves the way `chrome.storage.local` does: what goes in is
// serialized, so the caller keeps no handle on what came to rest there, and
// what comes out is a fresh object the caller may mutate freely.
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
// Test-only: what the extension would find on a cold start.
_raw: () => structuredClone(store),
};
}
// ------------------------------------------------------------ harness
function wallet(name, secret, addresses) {
@@ -144,10 +118,10 @@ function load() {
jest.resetModules();
mockSettingsShow.mockClear();
const storage = makeStorage();
const storage = makeStorageStub();
const sent = [];
globalThis.chrome = {
storage: { local: storage },
storage: { local: storage.local },
runtime: { sendMessage: (msg) => sent.push(msg) },
};
globalThis.document = makeDocument();
@@ -205,7 +179,7 @@ async function openLostPassword(deleteWallet, walletIdx) {
// every other test in this file against a build that never writes.
describe("the storage stub", () => {
test("does not hand back the object it was given", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
const written = { wallets: [{ name: "Wallet 1" }] };
await storage.set({ autistmask: written });
@@ -393,8 +367,8 @@ describe("deleting without the password", () => {
// The deleted wallet's secret is gone from storage entirely, not
// merely unreferenced by the wallet list.
expect(JSON.stringify(storage._raw())).not.toContain("secret-two");
expect(JSON.stringify(storage._raw())).not.toContain("xpub-Wallet 2");
expect(JSON.stringify(storage.read())).not.toContain("secret-two");
expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
});
test("only the deleted wallet's site permissions are dropped", async () => {
@@ -462,7 +436,7 @@ describe("deleting without the password", () => {
expect(saved.activeAddress).toBeNull();
expect(saved.allowedSites).toEqual({});
expect(state.currentView).toBe("welcome");
expect(JSON.stringify(storage._raw())).not.toContain("secret-one");
expect(JSON.stringify(storage.read())).not.toContain("secret-one");
});
});

View File

@@ -10,32 +10,12 @@
//
// Both cases below drive the real state.js module through two independent
// module registries sharing one storage backend, the way two real extension
// pages share one chrome.storage.local. The storage stub structured-clones
// on both get and set — a stub that hands back the object it was given
// aliases the caller's own mutation and would make this entire defect class
// invisible (see https://git.eeqj.de/sneak/AutistMask/issues/324).
// pages share one chrome.storage.local. The shared stub structured-clones on
// both get and set — a stub that hands back the object it was given aliases
// the caller's own mutation and would make this entire defect class invisible
// (see https://git.eeqj.de/sneak/AutistMask/issues/324).
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
};
}
const { makeStorageStub } = require("./support/storageStub");
// One extension page: a fresh module registry over the shared storage.
// state.js resolves the storage API at require time, so the stub has to be
@@ -43,7 +23,7 @@ function makeStorage() {
// singleton, so each page needs its own registry to hold its own copy.
function loadPage(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage } };
globalThis.chrome = { storage: { local: storage.local } };
return {
state: require("../src/shared/state"),
helpers: require("../src/popup/views/helpers"),
@@ -118,7 +98,7 @@ describe("a save from a page that never saw a wallet another page added", () =>
// a save from a second page loaded before that wallet existed. Both
// wallets must survive.
test("both wallets are in storage afterwards", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// Loaded while storage held only Wallet 1, and never reloads —
@@ -171,7 +151,7 @@ describe("the approval-window reproduction", () => {
test("the wallet added in the popup survives confirming the approval", async () => {
globalThis.document = makeDocument();
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// The background opens the approval window on the approve-tx
@@ -223,7 +203,7 @@ describe("the approval-window reproduction", () => {
// membership" collided as the same field.
describe("background refresh racing a wallet added on another page", () => {
test("the wallet added elsewhere survives background's stale balance save", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// "background": loads first, and its save is the one that lands
@@ -263,7 +243,7 @@ describe("background refresh racing a wallet added on another page", () => {
describe("background refresh racing a wallet deleted on another page", () => {
test("the wallet deleted elsewhere stays deleted after background's stale balance save", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1, W2] } });
const background = loadPage(storage);
@@ -324,7 +304,7 @@ function revokeSite(pageState, hostname) {
describe("a dApp approval racing a stale Settings page's later save", () => {
test("the fresh approval survives Settings revoking an unrelated site", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({
autistmask: {
wallets: [W1],
@@ -362,7 +342,7 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
describe("a revoked site permission against a stale page's later save", () => {
test("the revocation holds even when the stale page approves something else", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({
autistmask: {
wallets: [W1],
@@ -413,7 +393,7 @@ function legacyWallet(name, secret) {
describe("two wallets independently created with a colliding identity", () => {
test("both survive, encryptedSecret included, instead of one silently replacing the other", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// Both pages load before either has created their malformed wallet,