fix: any web page can switch the network and destroy the user's custom RPC endpoint, unprompted and unconnected #308

Closed
opened 2026-08-20 11:59:14 +02:00 by clawbot · 2 comments
Collaborator

Found by the pre-1.0 deployability audit (#303). Blocker: chain confusion and silent loss of node self-custody, reachable by any page.

src/background/index.js:674-692 handles wallet_switchEthereumChain with no allowedSites/connectedSites check and no approval window, unlike every sibling method. src/shared/chainSwitch.js:47-49 then overwrites state.rpcUrl and state.blockscoutUrl with the network defaults.

Reproduction

The origin was never connected — eth_accounts for it does not return an address:

F: user's custom rpcUrl set to http://127.0.0.1:8545 on mainnet
F: wallet_switchEthereumChain(sepolia) -> {"ok":null}
F: state after -> {"networkId":"sepolia",
                   "rpcUrl":"https://ethereum-sepolia-rpc.publicnode.com",
                   "blockscoutUrl":"https://eth-sepolia.blockscout.com/api/v2"}
F: popup banner now reads: "[TESTNET] (welcome)"
F: switch back -> switched {"networkId":"mainnet","rpcUrl":"https://ethereum-rpc.publicnode.com"}

Consequence

Two losses.

  1. Node self-custody and privacy. A user running a local or private RPC has it silently replaced with a third-party public endpoint that then sees every address they hold and every transaction they send — permanently, with no notification and no undo.
  2. Chain confusion. A page can flip the wallet to mainnet while the user believes they are on Sepolia, removing the [TESTNET] banner, so a "test" send spends real ETH.

Precondition: the user visits a hostile page. That is the entire precondition.

Definition of done

  • wallet_switchEthereumChain is gated on the same connection check the signing methods use, and returns 4100 for an unconnected origin.
  • onChainSwitch does not clobber a user-set rpcUrl/blockscoutUrl — remember one endpoint per network instead of overwriting with defaults.
  • Test: unconnected origin is refused; connected origin switches; a custom RPC survives a switch away and back.
  • make check green.
Found by the pre-1.0 deployability audit (https://git.eeqj.de/sneak/AutistMask/issues/303). **Blocker: chain confusion and silent loss of node self-custody, reachable by any page.** `src/background/index.js:674-692` handles `wallet_switchEthereumChain` with **no `allowedSites`/`connectedSites` check and no approval window**, unlike every sibling method. `src/shared/chainSwitch.js:47-49` then overwrites `state.rpcUrl` and `state.blockscoutUrl` with the network defaults. ## Reproduction The origin was never connected — `eth_accounts` for it does not return an address: ``` F: user's custom rpcUrl set to http://127.0.0.1:8545 on mainnet F: wallet_switchEthereumChain(sepolia) -> {"ok":null} F: state after -> {"networkId":"sepolia", "rpcUrl":"https://ethereum-sepolia-rpc.publicnode.com", "blockscoutUrl":"https://eth-sepolia.blockscout.com/api/v2"} F: popup banner now reads: "[TESTNET] (welcome)" F: switch back -> switched {"networkId":"mainnet","rpcUrl":"https://ethereum-rpc.publicnode.com"} ``` ## Consequence Two losses. 1. **Node self-custody and privacy.** A user running a local or private RPC has it silently replaced with a third-party public endpoint that then sees every address they hold and every transaction they send — permanently, with no notification and no undo. 2. **Chain confusion.** A page can flip the wallet to mainnet while the user believes they are on Sepolia, removing the `[TESTNET]` banner, so a "test" send spends real ETH. Precondition: the user visits a hostile page. That is the entire precondition. ## Definition of done - [ ] `wallet_switchEthereumChain` is gated on the same connection check the signing methods use, and returns `4100` for an unconnected origin. - [ ] `onChainSwitch` does not clobber a user-set `rpcUrl`/`blockscoutUrl` — remember one endpoint per network instead of overwriting with defaults. - [ ] Test: unconnected origin is refused; connected origin switches; a custom RPC survives a switch away and back. - [ ] `make check` green.
clawbot added this to the 1.0.0 milestone 2026-08-20 11:59:14 +02:00
Author
Collaborator

Plan.

  1. Gate: wallet_switchEthereumChain gets the same allowedSites/connectedSites check the signing methods use, at the top of the handler (before the same-chain early return), returning { code: 4100, message: "Unauthorized" }.
  2. Endpoint memory: new persisted state.networkEndpoints{ [networkId]: { rpcUrl, blockscoutUrl } }. onChainSwitch() snapshots the outgoing network's live endpoints into it, then restores the incoming network's remembered pair, falling back to that network's defaults. state.rpcUrl/state.blockscoutUrl stay the live active values, so no reader changes. Invariant: for the active network state.rpcUrl is authoritative and the map may be stale; for every other network the map is authoritative.
  3. Back-compat: loadState() seeds networkEndpoints[networkId] from the stored rpcUrl/blockscoutUrl when the key is absent, so a profile written by the current release keeps its custom endpoint as that network's remembered pair.
  4. Tests in a new tests/chainSwitch.test.js: unconnected origin refused with 4100 and the network unmoved; connected origin switches and gets chainChanged; a custom RPC survives a switch away and back; the pre-change stored shape loads without loss.
Plan. 1. Gate: `wallet_switchEthereumChain` gets the same `allowedSites`/`connectedSites` check the signing methods use, at the top of the handler (before the same-chain early return), returning `{ code: 4100, message: "Unauthorized" }`. 2. Endpoint memory: new persisted `state.networkEndpoints` — `{ [networkId]: { rpcUrl, blockscoutUrl } }`. `onChainSwitch()` snapshots the outgoing network's live endpoints into it, then restores the incoming network's remembered pair, falling back to that network's defaults. `state.rpcUrl`/`state.blockscoutUrl` stay the live active values, so no reader changes. Invariant: for the active network `state.rpcUrl` is authoritative and the map may be stale; for every other network the map is authoritative. 3. Back-compat: `loadState()` seeds `networkEndpoints[networkId]` from the stored `rpcUrl`/`blockscoutUrl` when the key is absent, so a profile written by the current release keeps its custom endpoint as that network's remembered pair. 4. Tests in a new `tests/chainSwitch.test.js`: unconnected origin refused with `4100` and the network unmoved; connected origin switches and gets `chainChanged`; a custom RPC survives a switch away and back; the pre-change stored shape loads without loss.
Author
Collaborator

Built as planned; PR #313, branch issue-308-chain-switch-gate, base next.

Gate: wallet_switchEthereumChain takes the same allowedSites/connectedSites check the signing methods take, ahead of the same-chain and unsupported-chain answers, and returns { code: 4100, message: "Unauthorized" } for an unconnected origin.

Clobber: endpoints are remembered per network in a new persisted state.networkEndpoints — the switch snapshots the network being left and restores the network being entered, falling back to that network's defaults. state.rpcUrl/state.blockscoutUrl remain the live values for the active network, so no reader changed. An existing install's stored rpcUrl stays the live endpoint and is additionally adopted as the remembered pair of the network it was stored under, so nothing is lost on first load.

Verified: 11 new tests across tests/chainSwitchGate.test.js (unconnected refused with the state unmoved and no chainChanged; connected switches; 4902 still returned for a connected origin on an unsupported chain) and tests/networkEndpoints.test.js (custom rpc and blockscout survive a switch away and back, across an extension restart, and from a pre-change stored profile). 9 of the 11 fail against the unfixed src/. make check green: 33 suites, 759 tests, test-verify-build 18 cases, check-censored clean, lint executed in the pinned container.

Built as planned; PR [#313](https://git.eeqj.de/sneak/AutistMask/pulls/313), branch `issue-308-chain-switch-gate`, base `next`. Gate: `wallet_switchEthereumChain` takes the same `allowedSites`/`connectedSites` check the signing methods take, ahead of the same-chain and unsupported-chain answers, and returns `{ code: 4100, message: "Unauthorized" }` for an unconnected origin. Clobber: endpoints are remembered per network in a new persisted `state.networkEndpoints` — the switch snapshots the network being left and restores the network being entered, falling back to that network's defaults. `state.rpcUrl`/`state.blockscoutUrl` remain the live values for the active network, so no reader changed. An existing install's stored `rpcUrl` stays the live endpoint and is additionally adopted as the remembered pair of the network it was stored under, so nothing is lost on first load. Verified: 11 new tests across `tests/chainSwitchGate.test.js` (unconnected refused with the state unmoved and no `chainChanged`; connected switches; `4902` still returned for a connected origin on an unsupported chain) and `tests/networkEndpoints.test.js` (custom rpc and blockscout survive a switch away and back, across an extension restart, and from a pre-change stored profile). 9 of the 11 fail against the unfixed `src/`. `make check` green: 33 suites, 759 tests, `test-verify-build` 18 cases, `check-censored` clean, lint executed in the pinned container.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#308