harden: a hostile ERC-20 symbol renders as live HTML in the popup — cross-origin iframe over the wallet UI #307

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

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:190 interpolates a token symbol into an innerHTML string with no escaping:

`<span>${symbol}</span>`

Source chain: contract symbol() → Blockscout → src/shared/balances.js:102-110 (item.token.symbol || "???", no filter, no length cap) → helpers.js:209src/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 no default-src, so frame-src, img-src, style-src and form-action are unrestricted. Script is blocked; a cross-origin iframe is not.

Reproduction

Real Chrome, real shipped manifest, stub symbol() returning a full-viewport iframe:

[P2] I: stub token symbol() now returns: "<iframe id=\"pwn\" src=\"https://dapp.e2e.test/\"
        style=\"position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999\"></iframe>"
[P2] I: iframes in the popup DOM = 1 | #pwn present = true
[P2] I: address-detail iframes = 1

Related defects on the same path:

  • escapeHtml (src/popup/views/helpers.js:292-296) is textContentinnerHTML, which does not escape " or ', and it is used inside attributes at helpers.js:403 (data-copy="${escapeHtml(text)}").
  • helpers.js:390 interpolates a URL into href with no escaping at all.
  • src/popup/views/transactionDetail.js:104-113 feeds the same raw symbol through that path.
  • src/popup/views/confirmTx.js:152-157 interpolates a warning message into innerHTML unescaped. Only static strings reach it today, but src/shared/etherscanLabels.js:47 builds a warning from 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

  • escapeHtml escapes &, <, >, " and '.
  • Every interpolation into an innerHTML string in src/popup/views/ goes through it — a grep for ${ in those files shows no unescaped interpolation, including the href at helpers.js:390 and the warning at confirmTx.js:152-157.
  • Both manifests add default-src 'self', so an escape that does slip cannot reach the network.
  • A length cap on displayed symbols.
  • Test: a token whose symbol() returns markup renders as literal text, and the popup DOM contains zero iframes.
  • make check green.
Found by the pre-1.0 deployability audit (https://git.eeqj.de/sneak/AutistMask/issues/303). **Blocker: attacker-controlled markup executes inside the extension's own UI.** `src/popup/views/helpers.js:190` interpolates a token symbol into an `innerHTML` string with no escaping: ```js `<span>${symbol}</span>` ``` 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 **no `default-src`**, so `frame-src`, `img-src`, `style-src` and `form-action` are unrestricted. Script is blocked; a cross-origin iframe is not. ## Reproduction Real Chrome, real shipped manifest, stub `symbol()` returning a full-viewport iframe: ``` [P2] I: stub token symbol() now returns: "<iframe id=\"pwn\" src=\"https://dapp.e2e.test/\" style=\"position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999\"></iframe>" [P2] I: iframes in the popup DOM = 1 | #pwn present = true [P2] I: address-detail iframes = 1 ``` Related defects on the same path: - `escapeHtml` (`src/popup/views/helpers.js:292-296`) is `textContent`→`innerHTML`, which does **not** escape `"` or `'`, and it is used inside attributes at `helpers.js:403` (`data-copy="${escapeHtml(text)}"`). - `helpers.js:390` interpolates a URL into `href` with no escaping at all. - `src/popup/views/transactionDetail.js:104-113` feeds the same raw symbol through that path. - `src/popup/views/confirmTx.js:152-157` interpolates a warning `message` into `innerHTML` unescaped. Only static strings reach it today, but `src/shared/etherscanLabels.js:47` builds a `warning` from 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 - [ ] `escapeHtml` escapes `&`, `<`, `>`, `"` and `'`. - [ ] Every interpolation into an `innerHTML` string in `src/popup/views/` goes through it — a grep for `${` in those files shows no unescaped interpolation, including the `href` at `helpers.js:390` and the warning at `confirmTx.js:152-157`. - [ ] Both manifests add `default-src 'self'`, so an escape that does slip cannot reach the network. - [ ] A length cap on displayed symbols. - [ ] Test: a token whose `symbol()` returns markup renders as literal text, and the popup DOM contains zero iframes. - [ ] `make check` green.
clawbot added this to the 1.0.0 milestone 2026-08-20 11:59:05 +02:00
Author
Collaborator

Plan.

  1. escapeHtml becomes a pure string replace over & < > " ' in a new DOM-free src/shared/html.js, re-exported from src/popup/views/helpers.js so callers do not move. Pure means it is unit-testable without a DOM shim, and it stops depending on textContent/innerHTML round-tripping, which never escaped quotes.
  2. Full audit of every ${} in src/popup/views/, not just the four sites named above; each one either goes through escapeHtml or is annotated as locally computed.
  3. New src/shared/symbolDisplay.js with 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 not isSpoofedSymbol.
  4. CSP. default-src 'self' alone would break the popup in four ways, so the policy is written out rather than left to fall back:
    • connect-src must stay open — the RPC endpoint is user-configurable and the Firefox e2e suite points the wallet at an http://127.0.0.1 stub node, so 'self' https: http:.
    • img-src 'self' data: — blockies are data: PNGs assigned to img.src.
    • style-src 'self' 'unsafe-inline'index.html and the view helpers use inline style="..." attributes throughout, which CSP blocks without it.
    • frame-src 'none', form-action 'none', base-uri 'none'form-action and base-uri do not fall back to default-src, so they need naming. frame-src 'none' is what actually kills the reported attack.
      tests/manifest.test.js pins the policy in both directions today and gets extended to the new directive set.
  5. Chrome e2e: a hostile-symbol fixture whose 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.
Plan. 1. `escapeHtml` becomes a pure string replace over `& < > " '` in a new DOM-free `src/shared/html.js`, re-exported from `src/popup/views/helpers.js` so callers do not move. Pure means it is unit-testable without a DOM shim, and it stops depending on `textContent`/`innerHTML` round-tripping, which never escaped quotes. 2. Full audit of every `${}` in `src/popup/views/`, not just the four sites named above; each one either goes through `escapeHtml` or is annotated as locally computed. 3. New `src/shared/symbolDisplay.js` with 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 not `isSpoofedSymbol`. 4. CSP. `default-src 'self'` alone would break the popup in four ways, so the policy is written out rather than left to fall back: - `connect-src` must stay open — the RPC endpoint is user-configurable and the Firefox e2e suite points the wallet at an `http://127.0.0.1` stub node, so `'self' https: http:`. - `img-src 'self' data:` — blockies are `data:` PNGs assigned to `img.src`. - `style-src 'self' 'unsafe-inline'` — `index.html` and the view helpers use inline `style="..."` attributes throughout, which CSP blocks without it. - `frame-src 'none'`, `form-action 'none'`, `base-uri 'none'` — `form-action` and `base-uri` do not fall back to `default-src`, so they need naming. `frame-src 'none'` is what actually kills the reported attack. `tests/manifest.test.js` pins the policy in both directions today and gets extended to the new directive set. 5. Chrome e2e: a hostile-symbol fixture whose `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.
Author
Collaborator

Done in #327, branch issue-307-escape-html. The PR body carries the full account; the four DoD items and how each was verified:

  1. escapeHtml is now a pure string replace over &, <, >, " and ' in a new DOM-free src/shared/html.js, re-exported from helpers.js.
  2. Every ${} in src/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 blockie data: URI were also unescaped. Explorer URLs now go through one helper that percent-encodes the path segment.
  3. Both manifests declare default-src 'self' with frame-src 'none'. It does break things on its own: style-src needs 'unsafe-inline' for the popup's style="..." attributes, img-src needs data: for blockies, and connect-src needs https:/http: because the RPC endpoint is user-configurable. Each is justified in README.md and pinned exactly in tests/manifest.test.js.
  4. Displayed symbols capped at 12 characters — the bound lookupTokenInfo() already applied on the contract-read path. isSpoofedSymbol untouched.

Verification: make check green (39 suites, 811 tests, lint in the container). make test-e2e 55/55 with the new browser test —

# address-detail iframes = 0
# iframes in the popup DOM = 0 | #pwn present = false | symbol = "<iframe id=…"
ok 44 - a token whose symbol() returns markup renders as text (#307)

make test-e2e-firefox 8/8, which is what shows the CSP change does not break the popup in the other engine.

Failing-first, four ways: cutting escapeHtml back to & < > (what the old textContent round trip actually did) fails 5 unit tests; removing the cap fails 3; dropping default-src fails 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.

Done in https://git.eeqj.de/sneak/AutistMask/pulls/327, branch `issue-307-escape-html`. The PR body carries the full account; the four DoD items and how each was verified: 1. `escapeHtml` is now a pure string replace over `&`, `<`, `>`, `"` and `'` in a new DOM-free `src/shared/html.js`, re-exported from `helpers.js`. 2. Every `${}` in `src/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 blockie `data:` URI were also unescaped. Explorer URLs now go through one helper that percent-encodes the path segment. 3. Both manifests declare `default-src 'self'` with `frame-src 'none'`. It does break things on its own: `style-src` needs `'unsafe-inline'` for the popup's `style="..."` attributes, `img-src` needs `data:` for blockies, and `connect-src` needs `https:`/`http:` because the RPC endpoint is user-configurable. Each is justified in README.md and pinned exactly in `tests/manifest.test.js`. 4. Displayed symbols capped at 12 characters — the bound `lookupTokenInfo()` already applied on the contract-read path. `isSpoofedSymbol` untouched. Verification: `make check` green (39 suites, 811 tests, lint in the container). `make test-e2e` 55/55 with the new browser test — ``` # address-detail iframes = 0 # iframes in the popup DOM = 0 | #pwn present = false | symbol = "<iframe id=…" ok 44 - a token whose symbol() returns markup renders as text (#307) ``` `make test-e2e-firefox` 8/8, which is what shows the CSP change does not break the popup in the other engine. Failing-first, four ways: cutting `escapeHtml` back to `& < >` (what the old `textContent` round trip actually did) fails 5 unit tests; removing the cap fails 3; dropping `default-src` fails 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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#307