harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s

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:
2026-08-23 17:57:30 +02:00
parent 36bc6bee0e
commit bd0a626e7b
40 changed files with 2959 additions and 653 deletions

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");
});
});