Files
AutistMask/tests/backgroundStateIsolation.test.js
clawbot bd0a626e7b
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
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
2026-08-23 17:57:30 +02:00

399 lines
14 KiB
JavaScript

// What one background handler's state can do to another's while both are in
// flight.
//
// The background used to read and write the module-level `state` singleton in
// src/shared/state.js — one object, shared by every handler in the worker,
// replaced wholesale by any loadState(). Two consequences, both covered here
// and both from https://git.eeqj.de/sneak/AutistMask/issues/324:
//
// - A transaction attempt captured the chain id at its loadState() and then
// read the ENDPOINT off the singleton several awaits later. A chain switch
// committed in that window moved the endpoint under an artifact already
// verified against the old chain, so it would have gone to the new chain's
// node — the very thing the verification exists to prevent.
//
// - backgroundRefresh() handed the singleton's wallets to refreshBalances(),
// which mutates address objects in place across a multi-second network
// round trip. Any concurrent handler that loaded state replaced those
// objects, so the refreshed balances landed on detached ones and the save
// that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo.
//
// Both use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right.
const { Wallet } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
const NONCE = 7;
const REFRESHED_BALANCE = "1.5";
// The transaction the background populates, and the artifact signed from it.
// Its chain is a parameter because the whole subject here is a chain moving
// under work already committed to one.
function populated(chainId) {
return {
type: 2,
chainId,
nonce: NONCE,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
to: RECIPIENT,
value: 10000000000000000n,
data: "0x",
};
}
function storedProfile(networkId) {
const net = networkById(networkId);
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
xpub: "xpub-1",
addresses: [
{
address: signer.address,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: signer.address,
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
lastBalanceRefresh: 0,
};
}
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
function deferred() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
afterEach(() => {
delete global.chrome;
});
// The background worker over a cloning storage stub, with the network and the
// clock stubbed out. `opts.refreshBalances` replaces the balance refresh so a
// test can hold one open across another handler's whole turn.
function loadWorker(networkId, opts) {
const options = opts || {};
jest.resetModules();
// Every provider this worker constructs, in order, with the endpoint and
// the network id it was given. The subject of the first test is which pair
// reaches the broadcast.
const providers = [];
const broadcastTransaction = jest.fn(async () => ({ hash: "0xfeed" }));
jest.doMock("../src/shared/balances", () => ({
getProvider: (rpcUrl, networkId2) => {
const provider = {
rpcUrl,
networkId: networkId2,
broadcastTransaction,
getNetwork: async () => ({
chainId: BigInt(networkById(networkId2).networkVersion),
}),
getTransactionCount: async () => NONCE,
estimateGas: async () => 100000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
};
providers.push(provider);
return provider;
},
refreshBalances:
options.refreshBalances || jest.fn(async () => undefined),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
let alarmHandlers = {};
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn((handlers) => {
alarmHandlers = handlers;
}),
}));
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
// A hook the tests use to suspend one handler mid-flight, so the other one
// runs entirely inside its window.
let getHook = null;
const realGet = storage.local.get;
storage.local.get = jest.fn(async (key) => {
if (getHook) await getHook();
return realGet(key);
});
let messageListener = null;
// Every popup URL the background opened. The approval id is in it, and
// that is how the popup learns which approval it is answering.
const createdUrls = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (createOpts, cb) => {
createdUrls.push(createOpts.url);
cb({ id: createdUrls.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
function send(msg, sender) {
let result = null;
const kept = messageListener(msg, sender, (r) => {
result = r;
});
return { kept, result: () => result };
}
function rpc(method, params, origin) {
return send(
{ type: "AUTISTMASK_RPC", method, params },
{ origin: origin || CONNECTED_ORIGIN },
);
}
return {
rpc,
send,
providers,
broadcastTransaction,
persisted: () => storage.read("autistmask"),
setGetHook: (hook) => {
getHook = hook;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" },
fireBalanceAlarm: () => alarmHandlers.balance(),
lastApprovalId: () => {
const url = createdUrls[createdUrls.length - 1];
if (!url) return null;
return new URL(url, EXT_URL).searchParams.get("approval");
},
};
}
describe("a chain switch under a transaction already committed to a chain", () => {
// Item 4 of https://git.eeqj.de/sneak/AutistMask/issues/324.
//
// The artifact is verified against the chain read at the top of the
// attempt. Whatever endpoint it is then broadcast to has to be that same
// chain's — otherwise the wallet checks a transaction against Sepolia and
// sends it to a mainnet node. A connected site can switch the chain at any
// moment, including this one.
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
const bg = loadWorker("sepolia");
// Raise the approval, then find its id from the popup's own fetch.
bg.rpc("eth_sendTransaction", [
{
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0x",
},
]);
await settle();
const id = bg.lastApprovalId();
expect(id).toBeTruthy();
// The popup signs what it was shown: Sepolia.
const rawSignedTx = await signer.signTransaction(
populated(Number(SEPOLIA.networkVersion)),
);
// A connected site switches the chain while the attempt is running,
// and the switch is committed to storage in full before the attempt
// goes any further.
//
// It is fired from inside the attempt's SECOND state read, because
// that is where the window used to be: the chain id was captured at
// the first read and the endpoint was taken off the singleton several
// awaits later, so a switch landing between them moved the endpoint
// under an artifact already verified against the old chain. An
// implementation that takes both from one read has no second read for
// this to fire on, and the switch below runs after the attempt is
// done instead — which is the point.
let reads = 0;
let switched = null;
const doSwitch = async () => {
switched = bg.rpc("wallet_switchEthereumChain", [
{ chainId: MAINNET.chainId },
]);
await settle();
};
bg.setGetHook(async () => {
reads++;
if (reads !== 2) return;
bg.setGetHook(null);
await doSwitch();
});
const attempt = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx,
},
{ url: bg.fromPopup.url },
);
await settle();
bg.setGetHook(null);
if (!switched) await doSwitch();
expect(switched.result()).toEqual({ result: null });
expect(bg.persisted().networkId).toBe("mainnet");
await settle();
// It went out, and it went out to Sepolia's node — the chain the
// artifact was verified against. Reading the endpoint separately from
// the chain id put mainnet's here.
expect(attempt.result()).toEqual({ txHash: "0xfeed" });
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
const used = bg.providers[bg.providers.length - 1];
expect(used.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(used.networkId).toBe("sepolia");
});
});
describe("a balance refresh under another handler's state read", () => {
// Item 5 of https://git.eeqj.de/sneak/AutistMask/issues/324.
//
// The trigger is a same-chain wallet_switchEthereumChain from a connected
// site: it answers { result: null } and changes nothing, so the ONLY thing
// it can do to the refresh is what its state read does. On the singleton
// that read replaced state.wallets, detaching the objects the refresh was
// mutating.
test("a chain read arriving mid-refresh does not discard the refresh", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets) => {
reachedNetwork.resolve();
await roundTrip.promise;
// In place, on the objects handed in — as balances.js does.
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
const answered = bg.rpc("wallet_switchEthereumChain", [
{ chainId: SEPOLIA.chainId },
]);
await settle();
expect(answered.result()).toEqual({ result: null });
roundTrip.resolve();
await refresh;
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
expect(bg.persisted().lastBalanceRefresh).toBeGreaterThan(0);
});
// The other half of "does not publish a shared object": a wallet added
// while the refresh was in flight must survive the refresh's own write.
test("a wallet added mid-refresh survives the refresh's write", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets) => {
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// Another extension page adds a wallet while the round trip is out.
const during = bg.persisted();
during.wallets.push({
name: "Wallet 2",
type: "hd",
xpub: "xpub-2",
addresses: [
{ address: RECIPIENT, balance: "0.0", tokenBalances: [] },
],
});
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.wallets).toHaveLength(2);
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
});
});