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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user