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

@@ -24,6 +24,7 @@ const { Network, Wallet } = require("ethers");
// before any jest.doMock() of the module, so the copy assertions below check
// what the user is actually shown.
const { describeSigningFailure } = require("../src/shared/approvalVerify");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
@@ -137,22 +138,22 @@ function loadBackground(options) {
jest.resetModules();
const broadcastTransaction = jest.fn();
const loadState = jest.fn(opts.loadState || (async () => {}));
// The network the wallet is on, which the tests switch under a pending
// approval. The node the transaction is populated against is on the same
// one, as it would be: switching networks switches the RPC endpoint too.
let chain = MAINNET;
// The node the transaction is populated against is on whatever chain the
// stored profile says, as it would be: switching networks switches the RPC
// endpoint too. The background takes the network from storage per call —
// it holds no in-memory copy — so this reads the record rather than a
// variable the test keeps alongside it.
const chainOf = (networkId) =>
networkId === "sepolia" ? SEPOLIA : MAINNET;
jest.doMock("../src/shared/state", () => ({
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
loadState,
saveState: jest.fn(async () => {}),
currentNetwork: () => ({ chainId: chain.hex }),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () =>
fakeProvider(broadcastTransaction, opts.provider, chain.num),
getProvider: (rpcUrl, networkId) =>
fakeProvider(
broadcastTransaction,
opts.provider,
chainOf(networkId).num,
),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
@@ -177,12 +178,31 @@ function loadBackground(options) {
wallets: [
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
],
networkId: "mainnet",
rpcUrl: "https://rpc.invalid",
activeAddress: signer.address,
allowedSites: { [signer.address]: [HOSTNAME] },
deniedSites: {},
};
// The one wallet state there is. The background reads it per call and
// writes it read-modify-write; it holds no in-memory copy and cannot reach
// the shared singleton. Clones in both directions, as the real API does —
// the stub here used to hand back the live record and drop every write on
// the floor, so a test could neither see what was persisted nor be sure
// what it read had crossed the boundary
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
const storage = makeStorageStub({ autistmask: persisted });
// A test that needs the state read itself to misbehave installs a hook —
// a stall, a throw — in place of the next reads. Armed after setup so
// that raising the approval is not what fails.
let storageGetHook = opts.storageGet || null;
const realGet = storage.local.get;
storage.local.get = jest.fn(async (key) =>
storageGetHook ? storageGetHook(key) : realGet(key),
);
let messageListener = null;
let windowRemovedListener = null;
let connectListener = null;
@@ -194,15 +214,7 @@ function loadBackground(options) {
const actionPopups = [];
global.chrome = {
storage: {
local: {
get: jest.fn(
opts.storageGet ||
(async () => ({ autistmask: persisted })),
),
set: jest.fn(async () => {}),
},
},
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
@@ -401,18 +413,32 @@ function loadBackground(options) {
connectApproval,
closeWindow,
broadcastTransaction,
loadState,
created,
removed,
storage,
// The user switching account in the toolbar popup, as the background
// sees it: the persisted active address changes underneath a pending
// approval.
setActiveAddress: (address) => {
persisted.activeAddress = address;
storage.write("autistmask", {
...storage.read("autistmask"),
activeAddress: address,
});
},
// The user switching network in the toolbar popup.
// The user switching network in the toolbar popup. It moves the stored
// network and the endpoint together, as a real switch does.
setNetwork: (network) => {
chain = network;
const networkId = network === SEPOLIA ? "sepolia" : "mainnet";
storage.write("autistmask", {
...storage.read("autistmask"),
networkId,
rpcUrl: "https://rpc-" + networkId + ".invalid",
});
},
// Make the next state reads misbehave — stall, throw — without
// touching the reads that raised the approval. Pass null to restore.
setStateReadHook: (hook) => {
storageGetHook = hook;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" },
};
@@ -677,15 +703,16 @@ describe("one transaction approval at a time", () => {
// page never — and holds the slot for the life of the worker with it.
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
const stalled = deferred();
const bg = loadBackground({
loadState: async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
},
});
const bg = loadBackground();
const first = bg.requestTx();
await settle();
// Armed only now: the approval was raised against a working state
// read, and it is the ATTEMPT's read that hangs and then fails.
bg.setStateReadHook(async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
});
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
@@ -709,6 +736,7 @@ describe("one transaction approval at a time", () => {
error: { code: 4001, message: "User rejected the request." },
});
bg.setStateReadHook(null);
const second = bg.requestTx();
await settle();
expect(second.result()).toBeNull();
@@ -1226,19 +1254,18 @@ describe("what the approval is verified against", () => {
// The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => {
let failNext = true;
const bg = loadBackground({
loadState: async () => {
if (failNext) {
failNext = false;
throw new Error("storage unavailable");
}
},
});
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
// The attempt's state read fails once, then works: nothing was
// broadcast, so the approval must survive for the retry.
bg.setStateReadHook(() => {
bg.setStateReadHook(null);
throw new Error("storage unavailable");
});
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",