harden: a hostile ERC-20 symbol renders as live HTML in the popup — cross-origin iframe over the wallet UI #307
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by the pre-1.0 deployability audit (#303). Blocker: attacker-controlled markup executes inside the extension's own UI.
src/popup/views/helpers.js:190interpolates a token symbol into aninnerHTMLstring with no escaping:Source chain: contract
symbol()→ Blockscout →src/shared/balances.js:102-110(item.token.symbol || "???", no filter, no length cap) →helpers.js:209→src/popup/views/addressDetail.js:71,addressToken.js:155, and the Home wallet list.isSpoofedSymbol(src/shared/symbolSpoof.js:98) only rejects symbols that collide with a known ticker, so arbitrary markup passes it.The shipped CSP is
script-src 'self' 'wasm-unsafe-eval'; object-src 'self'(manifest/chrome.json:9,manifest/firefox.json:7) — there is nodefault-src, soframe-src,img-src,style-srcandform-actionare unrestricted. Script is blocked; a cross-origin iframe is not.Reproduction
Real Chrome, real shipped manifest, stub
symbol()returning a full-viewport iframe:Related defects on the same path:
escapeHtml(src/popup/views/helpers.js:292-296) istextContent→innerHTML, which does not escape"or', and it is used inside attributes athelpers.js:403(data-copy="${escapeHtml(text)}").helpers.js:390interpolates a URL intohrefwith no escaping at all.src/popup/views/transactionDetail.js:104-113feeds the same raw symbol through that path.src/popup/views/confirmTx.js:152-157interpolates a warningmessageintoinnerHTMLunescaped. Only static strings reach it today, butsrc/shared/etherscanLabels.js:47builds awarningfrom scraped Etherscan content — one wiring change from live.Consequence
An attacker-controlled page, served from any origin, rendered over the wallet's own UI — on screens where the user is accustomed to typing their password — plus an unauthenticated beacon confirming the wallet was opened. Precondition: the attacker deploys a token with at least 1,000 holders (
balances.js:92-96) and airdrops one unit to the victim. That is cheap and is a standard airdrop-spam operation.Definition of done
escapeHtmlescapes&,<,>,"and'.innerHTMLstring insrc/popup/views/goes through it — a grep for${in those files shows no unescaped interpolation, including thehrefathelpers.js:390and the warning atconfirmTx.js:152-157.default-src 'self', so an escape that does slip cannot reach the network.symbol()returns markup renders as literal text, and the popup DOM contains zero iframes.make checkgreen.Plan.
escapeHtmlbecomes a pure string replace over& < > " 'in a new DOM-freesrc/shared/html.js, re-exported fromsrc/popup/views/helpers.jsso callers do not move. Pure means it is unit-testable without a DOM shim, and it stops depending ontextContent/innerHTMLround-tripping, which never escaped quotes.${}insrc/popup/views/, not just the four sites named above; each one either goes throughescapeHtmlor is annotated as locally computed.src/shared/symbolDisplay.jswith a 16-character cap on displayed token symbols, applied where an explorer-sourced symbol reaches the DOM. 16 leaves headroom over the longest symbol in the bundled list (MSYRUPUSDP, 10). This is a layout/overflow bound, not the escaping control, and it is notisSpoofedSymbol.default-src 'self'alone would break the popup in four ways, so the policy is written out rather than left to fall back:connect-srcmust stay open — the RPC endpoint is user-configurable and the Firefox e2e suite points the wallet at anhttp://127.0.0.1stub node, so'self' https: http:.img-src 'self' data:— blockies aredata:PNGs assigned toimg.src.style-src 'self' 'unsafe-inline'—index.htmland the view helpers use inlinestyle="..."attributes throughout, which CSP blocks without it.frame-src 'none',form-action 'none',base-uri 'none'—form-actionandbase-urido not fall back todefault-src, so they need naming.frame-src 'none'is what actually kills the reported attack.tests/manifest.test.jspins the policy in both directions today and gets extended to the new directive set.symbol()answers with an iframe tag, asserting the text renders literally and the popup DOM holds zero iframes. Both browser suites get run for the CSP change, with counts reported.Done in #327, branch
issue-307-escape-html. The PR body carries the full account; the four DoD items and how each was verified:escapeHtmlis now a pure string replace over&,<,>,"and'in a new DOM-freesrc/shared/html.js, re-exported fromhelpers.js.${}insrc/popup/views/was audited. Beyond the four sites named here, the transaction lists' direction label (in all three lists), the wallet name and ENS name in the Home wallet list, and the blockiedata:URI were also unescaped. Explorer URLs now go through one helper that percent-encodes the path segment.default-src 'self'withframe-src 'none'. It does break things on its own:style-srcneeds'unsafe-inline'for the popup'sstyle="..."attributes,img-srcneedsdata:for blockies, andconnect-srcneedshttps:/http:because the RPC endpoint is user-configurable. Each is justified in README.md and pinned exactly intests/manifest.test.js.lookupTokenInfo()already applied on the contract-read path.isSpoofedSymboluntouched.Verification:
make checkgreen (39 suites, 811 tests, lint in the container).make test-e2e55/55 with the new browser test —make test-e2e-firefox8/8, which is what shows the CSP change does not break the popup in the other engine.Failing-first, four ways: cutting
escapeHtmlback to& < >(what the oldtextContentround trip actually did) fails 5 unit tests; removing the cap fails 3; droppingdefault-srcfails 2; removing both escape and cap and running the whole Chrome suite reproduces the attack — Playwright reported an<iframe id="pwn">intercepting pointer events over the Back button.One thing worth knowing for the reviewer:
frame-src 'none'stops such a frame loading but not existing, so a zero iframe count is a claim about the escaping alone, not about the CSP. The test asserts the count and the literal rendered text separately for that reason.