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:
@@ -2,19 +2,20 @@
|
||||
// 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.
|
||||
// The build enforces it, not review: build.js fails when esbuild's metafile
|
||||
// reports that module as an input of this bundle (FORBIDDEN_INPUTS in
|
||||
// script/lib/forbiddenBundleInputs.js). The ESLint rule of the same name is
|
||||
// the same prohibition reported early, not the guarantee.
|
||||
const { getState, updateState } = require("./state");
|
||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||
const { debugFetch, log } = require("../shared/log");
|
||||
const {
|
||||
@@ -42,7 +43,6 @@ const {
|
||||
const {
|
||||
actionApi,
|
||||
runtimeApi,
|
||||
storageGet,
|
||||
tabsQuery,
|
||||
tabsSendMessage,
|
||||
windowsApi,
|
||||
@@ -179,21 +179,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 +193,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 +206,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 +554,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 +605,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 +656,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 +669,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 +690,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 +700,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 +765,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 +791,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 +840,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 +896,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 +940,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 +1040,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 +1080,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 +1348,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 +1442,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);
|
||||
|
||||
Reference in New Issue
Block a user