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:
279
tests/coldWorkerSendTransaction.test.js
Normal file
279
tests/coldWorkerSendTransaction.test.js
Normal file
@@ -0,0 +1,279 @@
|
||||
// Which chain a dApp transaction is PREPARED for on a worker that has not
|
||||
// loaded state.
|
||||
//
|
||||
// The MV3 service worker is terminated when idle — roughly 30 seconds, which
|
||||
// is its normal condition — and revived by the page's own message. Nothing
|
||||
// loads state at module scope, so handleSendTransaction() used to build its
|
||||
// provider with `getProvider(await getRpcUrl())`: the endpoint came from
|
||||
// storage and was right, and the static network hint was omitted, so
|
||||
// src/shared/balances.js fell back to currentNetwork() — the unpopulated
|
||||
// singleton — and answered mainnet. ethers then fixed `chainId` at 0x1.
|
||||
//
|
||||
// The transaction was not sent on the wrong chain: verifySignedTx() compares
|
||||
// the artifact against the selected chain and refused it. So the guard held
|
||||
// and the feature did not — a user on any non-mainnet network could not send
|
||||
// from a dApp at all, and the error described the symptom
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
|
||||
//
|
||||
// This drives the real balances module and the real approval preparation and
|
||||
// verification. Only ethers' JsonRpcProvider is replaced, so the static
|
||||
// network hint getProvider() computes is the hint the population sees.
|
||||
|
||||
const { Network, Wallet, Transaction } = 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 SEPOLIA = networkById("sepolia");
|
||||
const MAINNET = networkById("mainnet");
|
||||
|
||||
const NONCE = 7;
|
||||
const TX_HASH = "0xfeed";
|
||||
|
||||
const TX_PARAMS = {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 60; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
// A worker whose only wallet state is what is in storage, with ethers'
|
||||
// JsonRpcProvider replaced by a stub that answers out of the static network it
|
||||
// was constructed with — which is exactly what a real staticNetwork provider
|
||||
// does, and what makes the chain id on the approval screen observable here.
|
||||
function loadColdWorker(networkId) {
|
||||
jest.resetModules();
|
||||
|
||||
const constructed = [];
|
||||
const broadcast = [];
|
||||
|
||||
jest.doMock("ethers", () => {
|
||||
const actual = jest.requireActual("ethers");
|
||||
class StubJsonRpcProvider {
|
||||
constructor(url, network) {
|
||||
this._network = network;
|
||||
constructed.push({ url, network });
|
||||
}
|
||||
async getNetwork() {
|
||||
return this._network;
|
||||
}
|
||||
async getTransactionCount() {
|
||||
return NONCE;
|
||||
}
|
||||
async estimateGas() {
|
||||
return 100000n;
|
||||
}
|
||||
async getFeeData() {
|
||||
return {
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
};
|
||||
}
|
||||
async broadcastTransaction(raw) {
|
||||
broadcast.push(raw);
|
||||
return { hash: TX_HASH };
|
||||
}
|
||||
}
|
||||
return { ...actual, JsonRpcProvider: StubJsonRpcProvider };
|
||||
});
|
||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||
isPhishingDomain: () => false,
|
||||
}));
|
||||
jest.doMock("../src/shared/alarms", () => ({
|
||||
BALANCE_REFRESH_ALARM: "balance",
|
||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||
registerAlarmHandlers: jest.fn(),
|
||||
}));
|
||||
|
||||
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
|
||||
|
||||
let messageListener = null;
|
||||
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: (opts, cb) => {
|
||||
createdUrls.push(opts.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;
|
||||
messageListener(msg, sender, (r) => {
|
||||
result = r;
|
||||
});
|
||||
return () => result;
|
||||
}
|
||||
|
||||
return {
|
||||
send,
|
||||
constructed,
|
||||
broadcast,
|
||||
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
||||
// The first message this worker ever sees, as the injected provider
|
||||
// sends it.
|
||||
sendTransaction: () =>
|
||||
send(
|
||||
{
|
||||
type: "AUTISTMASK_RPC",
|
||||
method: "eth_sendTransaction",
|
||||
params: [TX_PARAMS],
|
||||
},
|
||||
{ origin: CONNECTED_ORIGIN },
|
||||
),
|
||||
approvalId: () => {
|
||||
const url = createdUrls[createdUrls.length - 1];
|
||||
return url
|
||||
? new URL(url, EXT_URL).searchParams.get("approval")
|
||||
: null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// What the approval window does: fetch the approval and sign the transaction
|
||||
// it was handed, exactly as given.
|
||||
function signApproved(approvedTx) {
|
||||
const tx = {};
|
||||
for (const [key, value] of Object.entries(approvedTx)) {
|
||||
if (key === "from") continue;
|
||||
tx[key] = value;
|
||||
}
|
||||
return signer.signTransaction(tx);
|
||||
}
|
||||
|
||||
describe("a dApp transaction prepared by a worker that never loaded state", () => {
|
||||
test("a cold send on Sepolia reaches the approval screen and goes out", async () => {
|
||||
const bg = loadColdWorker("sepolia");
|
||||
|
||||
const answer = bg.sendTransaction();
|
||||
await settle();
|
||||
|
||||
// The provider was built for Sepolia, endpoint and static hint
|
||||
// together. Omitting the hint made this mainnet.
|
||||
expect(bg.constructed).toHaveLength(1);
|
||||
expect(bg.constructed[0].url).toBe(SEPOLIA.defaultRpcUrl);
|
||||
expect(bg.constructed[0].network.chainId).toBe(
|
||||
Network.from("sepolia").chainId,
|
||||
);
|
||||
|
||||
// So the approval the user is shown is a Sepolia transaction.
|
||||
const id = bg.approvalId();
|
||||
expect(id).toBeTruthy();
|
||||
const approval = bg.send(
|
||||
{ type: "AUTISTMASK_GET_APPROVAL", id },
|
||||
{ url: bg.fromPopup.url },
|
||||
)();
|
||||
expect(approval.type).toBe("tx");
|
||||
expect(approval.approvedTx.chainId).toBe(SEPOLIA.chainId);
|
||||
|
||||
// And it survives the wallet's own verification, which is where a
|
||||
// 0x1-stamped artifact was refused as "for a different network".
|
||||
const rawSignedTx = await signApproved(approval.approvedTx);
|
||||
const response = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx,
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(response()).toEqual({ txHash: TX_HASH });
|
||||
expect(bg.broadcast).toEqual([rawSignedTx]);
|
||||
expect(Number(Transaction.from(rawSignedTx).chainId)).toBe(
|
||||
Number(SEPOLIA.networkVersion),
|
||||
);
|
||||
expect(answer()).toEqual({ result: TX_HASH });
|
||||
});
|
||||
|
||||
test("a cold send on mainnet is prepared for mainnet", async () => {
|
||||
// The stored value and the old fallback agree here, so this case
|
||||
// cannot catch the defect; it is what keeps the fix from being a swap.
|
||||
const bg = loadColdWorker("mainnet");
|
||||
|
||||
bg.sendTransaction();
|
||||
await settle();
|
||||
|
||||
expect(bg.constructed[0].url).toBe(MAINNET.defaultRpcUrl);
|
||||
const approval = bg.send(
|
||||
{ type: "AUTISTMASK_GET_APPROVAL", id: bg.approvalId() },
|
||||
{ url: bg.fromPopup.url },
|
||||
)();
|
||||
expect(approval.approvedTx.chainId).toBe(MAINNET.chainId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user