From 28d5dddb965b5505858762246ea58ead488ef874 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 20 Aug 2026 10:10:43 +0000 Subject: [PATCH] fix: gate the chain switch and remember endpoints per network (closes #308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wallet_switchEthereumChain was answered for any origin at all, with no connection check and no prompt, so a page the user had never connected to could move the active chain — clearing the [TESTNET] banner under someone who believed they were on Sepolia. It now takes the same allowedSites/connectedSites gate the signing methods take, ahead of the same-chain and unsupported-chain answers, and refuses an unconnected origin with 4100. The switch also overwrote state.rpcUrl and state.blockscoutUrl with the network defaults, so a user running their own node lost that url permanently and silently to a public endpoint that then sees every address they hold. Endpoints are now remembered per network in state.networkEndpoints: the switch snapshots the network being left and restores the network being entered, falling back to that network's defaults. state.rpcUrl and state.blockscoutUrl remain the live endpoints of the active network, so no reader changed; for the active network they are authoritative and the map entry may be stale, and the snapshot is what reconciles them. A profile written before the map existed has its stored pair adopted for the network it was stored under, so nothing is lost on first load. --- TODO.md | 17 +++ src/background/index.js | 17 +++ src/shared/chainSwitch.js | 21 ++- src/shared/state.js | 23 ++++ tests/chainSwitchGate.test.js | 232 +++++++++++++++++++++++++++++++++ tests/e2e/run.js | 5 +- tests/networkEndpoints.test.js | 174 +++++++++++++++++++++++++ 7 files changed, 485 insertions(+), 4 deletions(-) create mode 100644 tests/chainSwitchGate.test.js create mode 100644 tests/networkEndpoints.test.js diff --git a/TODO.md b/TODO.md index 8abfb3a..fa09c79 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,23 @@ but the review is broader than any of them. # Completed Steps +- 2026-08-20: A web page can no longer switch the wallet's chain, and switching + no longer destroys the user's endpoints + ([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)). + `wallet_switchEthereumChain` was answered for any origin at all, with no + connection check and no prompt: any page could clear the `[TESTNET]` banner + under a user who believed they were on Sepolia. It now takes the same + `allowedSites`/`connectedSites` gate the signing methods take, ahead of the + same-chain and unsupported-chain answers, and refuses an unconnected origin + with `4100`. The switch itself also overwrote `state.rpcUrl` and + `state.blockscoutUrl` with the network defaults, so a user running their own + node lost that url permanently and silently to a public endpoint that then + sees every address they hold. Endpoints are now remembered per network in + `state.networkEndpoints`, snapshotted from the network being left and restored + for the network being entered; `state.rpcUrl` stays the live value for the + active network, so no reader changed. A profile written before the map existed + has its stored pair adopted for the network it was stored under, and loses + nothing. - 2026-08-17: The Settings screen is driven in a browser, and every element id the popup looks up is checked statically. Nothing exercised Settings in the e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups diff --git a/src/background/index.js b/src/background/index.js index 21a0302..2870fcd 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -672,6 +672,23 @@ async function handleRpc(method, params, origin) { } if (method === "wallet_switchEthereumChain") { + // Gated exactly like the signing methods, and gated before the + // same-chain early return. Switching the chain is wallet-wide: it + // moves the network the popup shows and the endpoints every other + // tab is served from, so a page the user never connected to must + // 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 hostname = extractHostname(origin); + const allowed = s.allowedSites[activeAddress] || []; + if ( + !allowed.includes(hostname) && + !connectedSites[origin + ":" + activeAddress] + ) { + return { error: { code: 4100, message: "Unauthorized" } }; + } + const chainId = params?.[0]?.chainId; if (chainId === currentNetwork().chainId) { return { result: null }; diff --git a/src/shared/chainSwitch.js b/src/shared/chainSwitch.js index 96d7bd3..feeda38 100644 --- a/src/shared/chainSwitch.js +++ b/src/shared/chainSwitch.js @@ -19,9 +19,26 @@ async function onChainSwitch(newNetworkId) { const net = networkById(newNetworkId); // --- core identity --- + // Endpoints are remembered per network rather than reset to the + // defaults, because a user who points the wallet at their own node has + // no way to get that URL back once it is gone: overwriting it moved + // every address and every transaction onto a third-party endpoint + // silently and permanently. + // + // state.rpcUrl / state.blockscoutUrl stay the live endpoints of the + // active network, so nothing that reads them changes. The invariant is + // that for the ACTIVE network those two fields are authoritative and + // the map entry may be stale (Settings writes the fields directly); + // for every other network the map is authoritative. Snapshotting the + // outgoing network here, before the switch, is what reconciles them. + state.networkEndpoints[state.networkId] = { + rpcUrl: state.rpcUrl, + blockscoutUrl: state.blockscoutUrl, + }; + const remembered = state.networkEndpoints[net.id] || {}; state.networkId = net.id; - state.rpcUrl = net.defaultRpcUrl; - state.blockscoutUrl = net.defaultBlockscoutUrl; + state.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl; + state.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl; // --- price cache --- // Prices are chain-specific (testnet tokens are worthless, diff --git a/src/shared/state.js b/src/shared/state.js index c7614a2..871ff5a 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -14,6 +14,11 @@ const DEFAULT_STATE = { networkId: "mainnet", rpcUrl: DEFAULT_RPC_URL, blockscoutUrl: DEFAULT_BLOCKSCOUT_URL, + // Endpoints remembered per network: { [networkId]: { rpcUrl, + // blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints + // of the active network; this is what the others are restored from + // when the active network changes. See onChainSwitch(). + networkEndpoints: {}, lastBalanceRefresh: 0, activeAddress: null, allowedSites: {}, @@ -34,6 +39,9 @@ const DEFAULT_STATE = { const state = { ...DEFAULT_STATE, + // Its own object, not the one DEFAULT_STATE holds: onChainSwitch() + // mutates this map in place, and a spread copies the reference. + networkEndpoints: {}, currentView: null, selectedWallet: null, selectedAddress: null, @@ -88,6 +96,7 @@ async function saveState() { networkId: state.networkId, rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl, + networkEndpoints: state.networkEndpoints, lastBalanceRefresh: state.lastBalanceRefresh, activeAddress: state.activeAddress, allowedSites: state.allowedSites, @@ -128,6 +137,20 @@ async function loadState() { state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; state.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl; + state.networkEndpoints = + saved.networkEndpoints && !Array.isArray(saved.networkEndpoints) + ? saved.networkEndpoints + : {}; + // A profile written before this map existed carries exactly one pair + // of endpoints, belonging to whatever network it was last on. Adopt + // it as that network's remembered pair, so a custom endpoint set on + // the old build is not lost by the first switch away and back. + if (!state.networkEndpoints[state.networkId]) { + state.networkEndpoints[state.networkId] = { + rpcUrl: state.rpcUrl, + blockscoutUrl: state.blockscoutUrl, + }; + } state.lastBalanceRefresh = saved.lastBalanceRefresh || 0; state.activeAddress = saved.activeAddress || null; state.allowedSites = diff --git a/tests/chainSwitchGate.test.js b/tests/chainSwitchGate.test.js new file mode 100644 index 0000000..f2213bc --- /dev/null +++ b/tests/chainSwitchGate.test.js @@ -0,0 +1,232 @@ +// Who may move the active chain. +// +// wallet_switchEthereumChain used to be answered for any origin at all, with +// no connection check and no prompt, so a page the user had never connected +// to could clear the [TESTNET] banner under someone who believed they were +// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal +// is asserted as a refusal to ACT — the state unmoved and no chainChanged +// broadcast — because an error code alone would not distinguish a gate from +// a switch that happened and then reported a failure. +// +// The endpoint half of that issue lives in tests/networkEndpoints.test.js; +// this file mocks the state module, which that one exercises for real. + +const { networkById } = require("../src/shared/networks"); + +const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; + +// The site the persisted state has connected, and one it has never heard of. +const CONNECTED_ORIGIN = "https://dapp.example"; +const CONNECTED_HOSTNAME = "dapp.example"; +const STRANGER_ORIGIN = "https://stranger.example"; + +const MAINNET = networkById("mainnet"); +const SEPOLIA = networkById("sepolia"); + +// The user's own node, so a switch that happens is visible as the loss of it. +const CUSTOM_RPC = "http://127.0.0.1:8545"; + +function walletFixture() { + return [ + { + name: "Wallet 1", + type: "hd", + addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], + }, + ]; +} + +// Let the handler's promise chain run to the next suspension point. The gate +// reads storage before it answers, so the response is several awaits deep. +async function settle() { + for (let i = 0; i < 50; i++) await Promise.resolve(); +} + +afterEach(() => { + delete global.chrome; +}); + +// --------------------------------------------------------------------------- +// The gate: which origins the background will switch the chain for. +// --------------------------------------------------------------------------- + +// Load the background worker against stubbed browser APIs, with the real +// chain-switch module behind it, and return the handles to drive it. The +// wallet state is a plain object so that a switch that DID happen is visible +// as a mutation of it, and one that did not is visible as its absence. +function loadBackground() { + jest.resetModules(); + + const walletState = { + networkId: "mainnet", + rpcUrl: CUSTOM_RPC, + blockscoutUrl: MAINNET.defaultBlockscoutUrl, + networkEndpoints: {}, + wallets: walletFixture(), + lastBalanceRefresh: 1, + tokenHolderCache: {}, + fraudContracts: [], + }; + + jest.doMock("../src/shared/state", () => ({ + state: walletState, + loadState: jest.fn(async () => {}), + saveState: jest.fn(async () => {}), + currentNetwork: () => networkById(walletState.networkId), + })); + jest.doMock("../src/shared/balances", () => ({ + getProvider: () => ({}), + refreshBalances: jest.fn(async () => {}), + })); + 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 persisted = { + wallets: walletFixture(), + activeAddress: ADDRESS, + allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] }, + deniedSites: {}, + }; + + let messageListener = null; + // Every message the background pushed at a content script. chainChanged + // is what tells a page the wallet moved, so an ungated switch is visible + // here as well as in the state. + const toTabs = []; + + global.chrome = { + storage: { + local: { + get: jest.fn(async () => ({ autistmask: persisted })), + set: jest.fn(async () => {}), + }, + }, + runtime: { + getURL: (path) => "chrome-extension://autistmask/" + path, + onMessage: { + addListener: (fn) => { + messageListener = fn; + }, + }, + onConnect: { addListener: () => {} }, + lastError: null, + }, + windows: { + getLastFocused: (cb) => cb(null), + create: (options, cb) => cb({ id: 1 }), + remove: (id, cb) => { + if (cb) cb(); + }, + onRemoved: { addListener: () => {} }, + }, + tabs: { + query: (queryInfo, cb) => cb([{ id: 1 }]), + sendMessage: (tabId, message, cb) => { + toTabs.push(message); + if (cb) cb(); + }, + }, + action: { setPopup: () => {} }, + }; + + require("../src/background/index"); + + async function switchChain(chainId, origin) { + let result = null; + messageListener( + { + type: "AUTISTMASK_RPC", + method: "wallet_switchEthereumChain", + params: [{ chainId }], + }, + { origin }, + (r) => { + result = r; + }, + ); + await settle(); + return result; + } + + return { + switchChain, + walletState, + chainChangedEvents: () => + toTabs.filter((m) => m.eventName === "chainChanged"), + }; +} + +describe("wallet_switchEthereumChain is gated on the connection", () => { + test("an origin the wallet was never connected to is refused with 4100", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN); + + expect(result.error).toEqual({ code: 4100, message: "Unauthorized" }); + expect(result.result).toBeUndefined(); + // The refusal has to be a refusal to ACT, not just an error string: + // the wallet is still on mainnet, still on the user's own node, and + // no page was told the chain moved. + expect(bg.walletState.networkId).toBe("mainnet"); + expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC); + expect(bg.chainChangedEvents()).toEqual([]); + }); + + test("an unconnected origin is refused even for the chain already active", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN); + + expect(result.error).toEqual({ code: 4100, message: "Unauthorized" }); + }); + + test("an unconnected origin is refused before the unsupported-chain answer", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain("0x89", STRANGER_ORIGIN); + + expect(result.error.code).toBe(4100); + }); + + test("a connected origin switches the chain", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); + + expect(result).toEqual({ result: null }); + expect(bg.walletState.networkId).toBe("sepolia"); + expect(bg.chainChangedEvents()).toEqual([ + { + type: "AUTISTMASK_EVENT", + eventName: "chainChanged", + data: SEPOLIA.chainId, + }, + ]); + }); + + test("a connected origin asking for an unsupported chain still gets 4902", async () => { + const bg = loadBackground(); + + const result = await bg.switchChain("0x89", CONNECTED_ORIGIN); + + expect(result.error.code).toBe(4902); + expect(bg.walletState.networkId).toBe("mainnet"); + }); + + test("a switch by a connected origin keeps the user's endpoint", async () => { + const bg = loadBackground(); + + await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN); + expect(bg.walletState.rpcUrl).toBe(SEPOLIA.defaultRpcUrl); + + await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN); + expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC); + }); +}); diff --git a/tests/e2e/run.js b/tests/e2e/run.js index ca33f12..2c7c37c 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -1191,8 +1191,9 @@ test("the theme and network selectors carry a non-default persisted value (#229) // and a selector stuck on `dark`/`sepolia` would otherwise be // indistinguishable here from one that persists correctly. Switching // the network back also returns state.rpcUrl and state.blockscoutUrl - // to the mainnet defaults that onChainSwitch() overwrote, which are - // the values src/shared/state.js starts with. + // to the mainnet endpoints onChainSwitch() remembered, which this + // fixture never customised and so are the mainnet defaults + // src/shared/state.js starts with. await env.page.selectOption("#settings-theme", "system"); await env.page.selectOption("#settings-network", "mainnet"); diff --git a/tests/networkEndpoints.test.js b/tests/networkEndpoints.test.js new file mode 100644 index 0000000..63189db --- /dev/null +++ b/tests/networkEndpoints.test.js @@ -0,0 +1,174 @@ +// What a chain switch is allowed to do to the endpoints the user configured. +// +// A switch used to overwrite state.rpcUrl and state.blockscoutUrl with the +// network defaults, so a user pointing the wallet at their own node lost that +// url the first time anything switched chains — with no notification and no +// way to recover it, having been moved onto a public endpoint that then sees +// every address they hold (https://git.eeqj.de/sneak/AutistMask/issues/308). +// Endpoints are now remembered per network, which is why the round trips +// below assert the ORIGINAL url comes back rather than only that the switch +// happened. + +const { networkById } = require("../src/shared/networks"); + +const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; + +const MAINNET = networkById("mainnet"); +const SEPOLIA = networkById("sepolia"); + +// The user's own node: the pair the switch used to throw away. +const CUSTOM_RPC = "http://127.0.0.1:8545"; +const CUSTOM_BLOCKSCOUT = "http://127.0.0.1:4000/api/v2"; + +function walletFixture() { + return [ + { + name: "Wallet 1", + type: "hd", + addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], + }, + ]; +} + +// The real state module against stubbed storage, plus whatever the last +// saveState() wrote — so a case can reload a fresh module from the bytes an +// earlier one persisted, which is what an extension restart does. `state` is +// a module-level singleton, so the registry has to be reset per load. +function loadModuleWith(persisted) { + jest.resetModules(); + let written = null; + global.chrome = { + storage: { + local: { + get: jest.fn(async () => + persisted ? { autistmask: persisted } : {}, + ), + set: jest.fn(async (items) => { + written = items.autistmask; + }), + }, + }, + }; + return { + mod: require("../src/shared/state"), + chainSwitch: require("../src/shared/chainSwitch"), + written: () => written, + }; +} + +afterEach(() => { + delete global.chrome; +}); + +describe("a custom endpoint survives a chain switch", () => { + test("switching away and back restores the user's rpc and blockscout urls", async () => { + const { mod, chainSwitch } = loadModuleWith({ + wallets: walletFixture(), + networkId: "mainnet", + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + networkEndpoints: { + mainnet: { + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + }, + }, + }); + await mod.loadState(); + + await chainSwitch.onChainSwitch("sepolia"); + // The new chain gets its own endpoints, not the ones belonging to the + // chain just left: a mainnet node cannot answer for Sepolia. + expect(mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl); + expect(mod.state.blockscoutUrl).toBe(SEPOLIA.defaultBlockscoutUrl); + + await chainSwitch.onChainSwitch("mainnet"); + expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); + expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); + }); + + test("an endpoint set on the network being left is remembered, not lost", async () => { + const { mod, chainSwitch } = loadModuleWith({ + wallets: walletFixture(), + networkId: "sepolia", + rpcUrl: SEPOLIA.defaultRpcUrl, + blockscoutUrl: SEPOLIA.defaultBlockscoutUrl, + networkEndpoints: {}, + }); + await mod.loadState(); + + // What the Settings screen does: write the live field, then save. The + // map entry for the active network is stale until the switch, which + // is what snapshotting the outgoing network exists to reconcile. + mod.state.rpcUrl = CUSTOM_RPC; + await mod.saveState(); + + await chainSwitch.onChainSwitch("mainnet"); + expect(mod.state.rpcUrl).toBe(MAINNET.defaultRpcUrl); + + await chainSwitch.onChainSwitch("sepolia"); + expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); + }); + + test("the remembered endpoints survive an extension restart", async () => { + const first = loadModuleWith({ + wallets: walletFixture(), + networkId: "mainnet", + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + }); + await first.mod.loadState(); + await first.chainSwitch.onChainSwitch("sepolia"); + + // Reload from exactly the bytes the switch persisted. + const second = loadModuleWith(first.written()); + await second.mod.loadState(); + expect(second.mod.state.networkId).toBe("sepolia"); + expect(second.mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl); + + await second.chainSwitch.onChainSwitch("mainnet"); + expect(second.mod.state.rpcUrl).toBe(CUSTOM_RPC); + expect(second.mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); + }); + + test("a profile written before networkEndpoints existed keeps its endpoint", async () => { + // Exactly the stored shape the current release writes: one pair of + // urls and no map. It is adopted as the remembered pair of the + // network it was stored under. + const { mod, chainSwitch } = loadModuleWith({ + wallets: walletFixture(), + networkId: "mainnet", + rpcUrl: CUSTOM_RPC, + blockscoutUrl: CUSTOM_BLOCKSCOUT, + }); + await mod.loadState(); + + expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); + expect(mod.state.networkEndpoints).toEqual({ + mainnet: { rpcUrl: CUSTOM_RPC, blockscoutUrl: CUSTOM_BLOCKSCOUT }, + }); + + await chainSwitch.onChainSwitch("sepolia"); + await chainSwitch.onChainSwitch("mainnet"); + expect(mod.state.rpcUrl).toBe(CUSTOM_RPC); + expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT); + }); + + test("a stored networkEndpoints of the wrong type is discarded", async () => { + const { mod } = loadModuleWith({ + wallets: walletFixture(), + networkId: "mainnet", + networkEndpoints: ["not", "a", "map"], + }); + await mod.loadState(); + + // Discarded, then seeded from the live endpoints the same way an old + // profile is — never left as something onChainSwitch() would index. + expect(mod.state.networkEndpoints).toEqual({ + mainnet: { + rpcUrl: MAINNET.defaultRpcUrl, + blockscoutUrl: MAINNET.defaultBlockscoutUrl, + }, + }); + }); +});