harden: make the background physically unable to read the shared state singleton (closes #324)
All checks were successful
check / check (push) Successful in 50s
e2e / e2e-chrome (push) Successful in 1m25s
e2e / e2e-firefox (push) Successful in 38s

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.

The rule's matcher covers every specifier syntax esbuild resolves statically —
quoted require, backtick require, dynamic import(), and a static import/export
`from` clause — because a narrower match is not a matter of tidiness but a sixth
site the build cannot see: each of those shapes was measured to put state.js in
dist/chrome/src/background/index.js while the lint stayed clean.
tests/backgroundStateLintRule.test.js pins all of them, plus the two-hop
re-export, against a real fixture tree. A computed specifier
(require("../shared/" + "state")) is deliberately not matched: esbuild cannot
resolve it either, so it never reaches the bundle.

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. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.

closes #320
This commit is contained in:
2026-08-23 13:43:07 +00:00
parent 669c443bf9
commit 277ec8c8f8
37 changed files with 2076 additions and 648 deletions

View File

@@ -2,19 +2,17 @@
// Handles EIP-1193 RPC requests from content scripts and proxies
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const { DEFAULT_RPC_URL } = require("../shared/constants");
const {
SUPPORTED_CHAIN_IDS,
networkById,
networkByChainId,
} = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch");
const {
state,
loadState,
saveState,
currentNetwork,
} = require("../shared/state");
const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
// The background's own storage layer. src/shared/state.js — the module-level
// `state` singleton, loadState() and saveState() — is deliberately NOT
// imported here and must never be: see the header of src/background/state.js,
// and the lint rule that enforces it in eslint.config.js.
const { getState, updateState } = require("./state");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log");
const {
@@ -42,7 +40,6 @@ const {
const {
actionApi,
runtimeApi,
storageGet,
tabsQuery,
tabsSendMessage,
windowsApi,
@@ -179,21 +176,12 @@ const INTERNAL_ERROR_CODE = -32603;
const INTERNAL_ERROR_MESSAGE =
"AutistMask could not complete this request because of an internal error.";
async function getState() {
const result = await storageGet("autistmask");
return (
result.autistmask || {
wallets: [],
rpcUrl: DEFAULT_RPC_URL,
activeAddress: null,
allowedSites: {},
deniedSites: {},
}
);
}
async function getActiveAddress() {
const s = await getState();
// The active address of a profile snapshot. Pure, and taking the snapshot as
// an argument rather than reading storage itself: a handler that has already
// read state must not answer "which account is this" from a SECOND, later read
// — the two can disagree, and the checks that compare them would then be
// comparing two different moments.
function activeAddressOf(s) {
if (s.activeAddress) return s.activeAddress;
// Fall back to first address
if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) {
@@ -202,6 +190,11 @@ async function getActiveAddress() {
return null;
}
// For the few call sites that need only the address and hold no snapshot.
async function getActiveAddress() {
return activeAddressOf(await getState());
}
// Whether a request names a signing address other than the active one. Such a
// request is refused rather than quietly signed as whichever address happens
// to be active: the page asked for account A and would otherwise be handed
@@ -210,9 +203,14 @@ function namesAnotherAddress(requested, activeAddress) {
return !!requested && !sameAddress(requested, activeAddress);
}
// The endpoint alone, for the one caller that needs nothing else. Anything
// that also needs the network the endpoint belongs to must take both from ONE
// snapshot — see handleSendTransaction() — because a chain switch moves them
// together and a provider built from two different reads can end up pointed at
// one chain and told it is on another
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
async function getRpcUrl() {
const s = await getState();
return s.rpcUrl || DEFAULT_RPC_URL;
return (await getState()).rpcUrl;
}
function extractHostname(origin) {
@@ -553,10 +551,26 @@ runtime.onConnect.addListener((port) => {
}
});
// Record a remembered site decision under one address.
//
// A read-modify-write against storage, not a load-mutate-save of a shared
// singleton: the user takes seconds to answer the prompt, and everything else
// in the worker — a balance refresh in flight, another site's approval — has
// gone on running the whole time. Loading here used to replace the very
// objects that work was holding.
async function rememberSiteChoice(field, address, hostname) {
await updateState((s) => {
if (!s[field][address]) s[field][address] = [];
if (!s[field][address].includes(hostname)) {
s[field][address].push(hostname);
}
});
}
// Handle connection requests (eth_requestAccounts, wallet_requestPermissions)
async function handleConnectionRequest(origin) {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress) {
return { error: { message: "No accounts available" } };
}
@@ -588,29 +602,14 @@ async function handleConnectionRequest(origin) {
if (decision.approved) {
if (decision.remember) {
// Reload state to get latest, add to allowed, persist
await loadState();
if (!state.allowedSites[activeAddress]) {
state.allowedSites[activeAddress] = [];
}
if (!state.allowedSites[activeAddress].includes(hostname)) {
state.allowedSites[activeAddress].push(hostname);
}
await saveState();
await rememberSiteChoice("allowedSites", activeAddress, hostname);
} else {
connectedSites[origin + ":" + activeAddress] = true;
}
return { result: [activeAddress] };
} else {
if (decision.remember) {
await loadState();
if (!state.deniedSites[activeAddress]) {
state.deniedSites[activeAddress] = [];
}
if (!state.deniedSites[activeAddress].includes(hostname)) {
state.deniedSites[activeAddress].push(hostname);
}
await saveState();
await rememberSiteChoice("deniedSites", activeAddress, hostname);
}
return {
error: {
@@ -654,7 +653,7 @@ async function handleRpc(method, params, origin) {
if (method === "eth_accounts") {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress) return { result: [] };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
@@ -667,22 +666,11 @@ async function handleRpc(method, params, origin) {
return { result: [] };
}
// Both answered from currentNetwork(), which reads the module-level state
// singleton, and nothing populates that at module scope. A worker revived
// by the page's own message therefore held DEFAULT_STATE and told a page
// it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317).
//
// Answered from getState() rather than by loading the singleton. Any page
// reaches these two — neither is gated on a connection, and the injected
// provider sends eth_chainId on every page load — and loadState() replaces
// state.wallets wholesale, which would detach the address objects an
// in-flight backgroundRefresh() is mutating across its network round trip,
// so its saveState() would persist the pre-refresh balances while still
// stamping lastBalanceRefresh. getState() is the detached per-call storage
// read the other read handlers here already use.
// networkById(undefined) falls back to mainnet, matching the default for a
// profile with no stored networkId.
// Both used to be answered from currentNetwork(), which reads the
// module-level state singleton, and nothing populates that at module
// scope. A worker revived by the page's own message therefore held
// DEFAULT_STATE and told a page it was on mainnet while the user was on
// Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/317).
if (method === "eth_chainId" || method === "net_version") {
const s = await getState();
const net = networkById(s.networkId);
@@ -699,7 +687,7 @@ async function handleRpc(method, params, origin) {
// not be able to do it. Ungated, any page could clear the
// [TESTNET] banner under a user who believed they were on Sepolia.
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
@@ -709,24 +697,25 @@ async function handleRpc(method, params, origin) {
return { error: { code: 4100, message: "Unauthorized" } };
}
// onChainSwitch() mutates the module-level state singleton and then
// saves every field of it, and currentNetwork() reads the same
// singleton. This worker may have been started by this very message:
// nothing loads state at module scope, so without this the singleton
// is DEFAULT_STATE, the same-chain check compares against the wrong
// network, and the save writes empty wallets, empty allowedSites and
// the default endpoints over the user's stored profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). Same precedent
// as the transaction path below.
await loadState();
// The chain in force is read from the snapshot above, not from the
// singleton: this worker may have been started by this very message,
// and the singleton would then be DEFAULT_STATE, so the same-chain
// check compared against mainnet whatever the user was on
// (https://git.eeqj.de/sneak/AutistMask/issues/316).
const chainId = params?.[0]?.chainId;
if (chainId === currentNetwork().chainId) {
if (chainId === networkById(s.networkId).chainId) {
return { result: null };
}
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
const target = networkByChainId(chainId);
await onChainSwitch(target.id);
// Read-modify-write against storage. The old path went through
// onChainSwitch(), which mutates the singleton and then persists
// every field of it — on an unloaded worker that wrote empty
// wallets, empty allowedSites and the default endpoints over the
// user's stored profile, encrypted secrets included.
await updateState((fresh) =>
applyChainSwitchFields(fresh, target.id),
);
broadcastChainChanged(target.chainId);
return { result: null };
}
@@ -773,7 +762,7 @@ async function handleRpc(method, params, origin) {
if (method === "wallet_getPermissions") {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
const isConnected =
@@ -799,7 +788,7 @@ async function handleRpc(method, params, origin) {
if (method === "personal_sign" || method === "eth_sign") {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
@@ -848,7 +837,7 @@ async function handleRpc(method, params, origin) {
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
@@ -904,7 +893,7 @@ async function handleRpc(method, params, origin) {
// page has its answer.
async function handleSendTransaction(params, origin) {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress) return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
@@ -948,10 +937,19 @@ async function handleSendTransaction(params, origin) {
// user is shown is a complete one and is the same object the signed
// artifact is checked against. A failure raises no approval at all and
// is reported to the requesting page; see approvalTx.js.
//
// The provider is built from ONE snapshot — the endpoint and the
// network name both come from `s`. It used to be
// getProvider(await getRpcUrl()) with no network name at all, so
// getProvider fell back to the unpopulated singleton's mainnet: the
// endpoint was the user's chain and the static hint was 0x1, ethers
// fixed chainId at 0x1, and the wallet's own verifySignedTx then
// refused every non-mainnet dApp send
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
let approvedTx;
try {
approvedTx = await prepareApprovalTx(
getProvider(await getRpcUrl()),
getProvider(s.rpcUrl, s.networkId),
activeAddress,
txParams,
);
@@ -1039,7 +1037,7 @@ async function broadcastAccountsChanged() {
}
resetPopupUrl();
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
let tabs;
try {
@@ -1079,20 +1077,60 @@ async function broadcastAccountsChanged() {
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// The wallets this refresh works on are its OWN, and nothing else in the
// worker can reach them.
//
// refreshBalances() mutates address objects in place across a multi-second
// network round trip. It used to be handed the module-level singleton's
// wallets, which meant any concurrent handler that called loadState() replaced
// state.wallets underneath it: the refreshed balances landed on detached
// objects, and the save that followed persisted the PRE-refresh values while
// still stamping lastBalanceRefresh, suppressing the redo. Every point fix for
// the singleton added such a loadState(), so the next one would have done it
// again (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// So: read a snapshot, refresh a private copy of its wallets, then apply the
// balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip.
async function backgroundRefresh() {
await loadState();
const s = await getState();
const now = Date.now();
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
return;
if (state.wallets.length === 0) return;
if (now - (s.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return;
if (s.wallets.length === 0) return;
const wallets = s.wallets;
await refreshBalances(
state.wallets,
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
wallets,
s.rpcUrl,
s.blockscoutUrl,
s.trackedTokens,
s.networkId,
);
state.lastBalanceRefresh = now;
await saveState();
const refreshed = new Map();
for (const wallet of wallets) {
for (const addr of wallet.addresses || []) {
refreshed.set(String(addr.address).toLowerCase(), addr);
}
}
await updateState((fresh) => {
for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase());
if (!got) continue;
// Only fields the refresh actually produced. refreshBalances()
// leaves a field untouched when its lookup failed, so an
// undefined here means "no answer", not "the answer is empty",
// and must not overwrite what is stored.
for (const key of ["balance", "ensName", "tokenBalances"]) {
if (got[key] !== undefined) addr[key] = got[key];
}
}
}
fresh.lastBalanceRefresh = now;
});
}
// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
@@ -1307,16 +1345,29 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// so an escape from there must not tell the user it might have.
let lastResortStage = TX_STAGE_VERIFY;
(async () => {
// The chain this attempt is on, read once. Verification below
// refuses an artifact signed for any other chain, and the nonce
// record is both consulted and written under this one, so a
// network switch part-way through cannot make the check and the
// record disagree about which chain the nonce was spent on.
// The chain this attempt is on, read once — and the endpoint it
// will be broadcast to comes from the SAME read.
//
// Verification below refuses an artifact signed for any other
// chain, and the nonce record is both consulted and written under
// this one, so a network switch part-way through cannot make the
// check and the record disagree about which chain the nonce was
// spent on. The endpoint used to be read separately, several
// awaits later (`state.rpcUrl` off the singleton), so a chain
// switch committed in that window moved the endpoint out from
// under a transaction already verified against the old chain: the
// artifact would be sent to the new chain's node, which is
// precisely the "signed for a different network" case the
// verification exists to prevent.
let chainId;
let rpcUrl;
let networkId;
try {
await loadState();
chainId = currentNetwork().chainId;
const activeAddress = await getActiveAddress();
const s = await getState();
networkId = s.networkId;
chainId = networkById(networkId).chainId;
rpcUrl = s.rpcUrl;
const activeAddress = activeAddressOf(s);
// An address switch between approval and signing refuses. The
// approval named one account; signing from whichever account
// is active now would send funds from an account this screen
@@ -1388,7 +1439,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
try {
const provider = getProvider(state.rpcUrl);
const provider = getProvider(rpcUrl, networkId);
lastResortStage = TX_STAGE_BROADCAST;
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
if (nonce !== null) spent.add(nonce);