harden: make the background physically unable to read the shared state singleton (closes #324)
Five defects traced to one fact: src/background/index.js read and wrote the module-level `state` singleton in src/shared/state.js, which the MV3 service worker never populates and which answered an unpopulated read out of DEFAULT_STATE in silence. Every previous fix added a loadState() before the access, and that is what produced the fifth: a load detaches the objects an in-flight handler is holding. So the reachability goes rather than a sixth call site. The background now has its own storage layer, src/background/state.js: getState() is a detached, normalized per-call read, and updateState() is a queued read-modify-write whose read is one storage round trip ahead of its write. Nothing in the background holds an in-memory copy of the profile. - Every handler takes one snapshot and answers from it, including the address it names: activeAddressOf(s) replaced a second, later storage read that could disagree with the first. - wallet_switchEthereumChain applies applyChainSwitchFields() (split out of chainSwitch.js, which keeps the singleton path for the popup) inside updateState() instead of calling onChainSwitch() on the singleton. - The remembered site decision is a read-modify-write, not a load-mutate-save around a prompt the user takes seconds to answer. - backgroundRefresh() refreshes a private copy of the wallets and applies the balances that came back by address, so it never publishes an object other in-flight work holds, and a wallet added or deleted during the round trip survives its write. - The transaction attempt takes its chain id and its endpoint from the same snapshot. They used to come from different moments, so a chain switch committed in between moved the endpoint under an artifact already verified against the old chain. getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it against networks.js. That closes the cold-worker wrong-chain send at its shape rather than at one call site: the hint used to default to currentNetwork() off the unpopulated singleton, so the endpoint was the user's chain and ethers fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses() and resolveEnsName() carry the id through; balances.js no longer requires state.js at all. The prohibition is enforced mechanically, not by review: a custom ESLint rule walks the CommonJS require graph from every src/background/ file and fails the lint when src/shared/state.js is reachable, naming the chain. A re-export from any shared module cannot put the singleton back in the bundle unnoticed. Reading a persisted field of the singleton before any load now throws StateNotLoadedError instead of serving DEFAULT_STATE. Test stubs: chrome.storage.local is a serialization boundary, and eight files stubbed it with an aliasing get, so the object a module held and the object "storage" held were one object — an assertion could pass on a build that never wrote anything. They all go through tests/support/storageStub.js now, which structured-clones in both directions. closes #320
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