From ada41bf5e14bfbfcc1c94aa38c6e13531a82e1aa Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 20 Aug 2026 11:34:41 +0000 Subject: [PATCH] fix: render a hostile token symbol as text, and put a floor under the CSP (closes #307) A token's symbol is whatever its symbol() returns, the block explorer passes it through unfiltered, and balanceLine() interpolated it into an innerHTML string. A token with the 1,000 holders the spam filter asks for, airdropped to the victim, could therefore paint a full-viewport cross-origin iframe over the wallet's own UI, on the screens where the user is used to typing their password. escapeHtml moves to the new src/shared/html.js as a pure string replace over &, <, >, " and '. The implementation it replaces round-tripped through a detached element's textContent, which escapes neither quote character, and it was already in use inside data-copy="..." and would have been inside href="...". Being pure also makes it testable without a DOM shim. Every interpolation into an innerHTML string across src/popup/views/ was audited rather than only the reported one. Also unescaped: the transaction lists' direction label (the explorer's method name, attacker-chosen for an attacker's contract), the wallet name and ENS name in the Home wallet list, the URL in the explorer link's href, the blockie data: URI, and the confirmation screen's warning line, which carries only fixed strings today but is one wiring change from carrying scraped explorer text. Explorer URLs are now built by one helper that percent-encodes the path segment, so a from/to out of explorer JSON cannot re-point the link. Where a value is a markup fragment this code just built, or a loop index, or a locally computed number, it stays bare; the rule and the reason are stated at the top of helpers.js. Both manifests now declare default-src 'self' with frame-src 'none'. Four directives had to stay looser than 'self' and none of them generalises: style-src needs 'unsafe-inline' because the popup sets presentation through style="..." attributes and Firefox has never implemented style-src-attr; img-src needs data: for the blockies; connect-src needs https: and http: because the RPC endpoint is user-configurable and a local node over http://127.0.0.1 is a supported configuration. frame-src, form-action and base-uri are named rather than inherited, because the last two do not fall back to default-src at all. tests/manifest.test.js now pins the whole directive set exactly, in both directions, and README.md carries the reasoning. Displayed symbols are capped at 12 characters, the bound lookupTokenInfo() already applied to a symbol read straight off a contract; the explorer path had none. The cap is a layout bound and is documented as not being the security control. isSpoofedSymbol() is untouched: it answers whether a symbol collides with a known ticker, which is a different question, and repurposing it here would have been the wrong control. Verified failing first, four ways. Restricting escapeHtml to & < > (the escape the old textContent round trip actually performed) fails 5 unit tests including the data-copy attribute break-out. Removing the length cap fails 3. Dropping default-src from manifest/chrome.json fails 2. Removing both the escape and the cap and running the full Chrome suite fails the new browser test with the attack reproduced: an '; + +describe("balanceLine", () => { + test("emits a hostile symbol as text, not as an element", () => { + // Deliberately asserted on the escaping alone. The cap truncates + // this payload before its id attribute, so an assertion about the + // rest of the payload would pass on the cap and say nothing about + // the escape. + const html = balanceLine(HOSTILE_SYMBOL, 1, null, null); + expect(html).not.toContain(" { + const html = balanceLine("A".repeat(4096), 1, null, null); + expect(html).toContain("A".repeat(MAX_SYMBOL_LENGTH - 1) + "…"); + expect(html).not.toContain("A".repeat(MAX_SYMBOL_LENGTH + 1)); + }); + + // The token id lands inside data-token="...", so a quote in it is a + // way out of the attribute and into a new one. + test("keeps a quote-bearing token id inside its attribute", () => { + const html = balanceLine("TKN", 1, null, '" onclick="alert(1)'); + expect(html).not.toContain('onclick="'); + expect(html).toContain('data-token="" onclick="alert(1)"'); + }); + + test("renders an ordinary holding unchanged", () => { + const html = balanceLine("USDC", 1.5, null, "0xabc"); + expect(html).toContain("USDC"); + expect(html).toContain("1.5000"); + expect(html).toContain('data-token="0xabc"'); + }); +}); diff --git a/tests/e2e/network.js b/tests/e2e/network.js index 7d05eb5..49d76ea 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -268,11 +268,15 @@ function ethCallResult(req, opts) { return ZERO_WORD; } -function tokenObject() { +// opts.tokenSymbolOverride is the hostile contract: set it and the explorer +// reports that string as the token's symbol, exactly as it would for a token +// whose symbol() returns markup. Read at request time, like every other +// fixture switch, so a test can flip it and reopen the popup. +function tokenObject(opts) { return { address_hash: STUB_TOKEN.address, address: STUB_TOKEN.address, - symbol: STUB_TOKEN.symbol, + symbol: (opts && opts.tokenSymbolOverride) || STUB_TOKEN.symbol, name: STUB_TOKEN.name, decimals: STUB_TOKEN.decimals, holders_count: STUB_TOKEN.holders, @@ -281,7 +285,7 @@ function tokenObject() { } // One received ERC-20 transfer of 1.5 E2E to the address under test. -function tokenTransferItems(address) { +function tokenTransferItems(address, opts) { return [ { transaction_hash: STUB_TX_HASH, @@ -290,7 +294,7 @@ function tokenTransferItems(address) { from: { hash: STUB_COUNTERPARTY }, to: { hash: address }, total: { decimals: STUB_TOKEN.decimals, value: "1500000" }, - token: tokenObject(), + token: tokenObject(opts), }, ]; } @@ -317,11 +321,11 @@ function nativeTransactionItems(address) { // A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving // this is what puts an ERC-20 in the send screen's token dropdown, which is // the only way the confirmation screen's ERC-20 path can be reached. -function tokenBalanceItems() { +function tokenBalanceItems(opts) { return [ { value: "1500000", - token: tokenObject(), + token: tokenObject(opts), }, ]; } @@ -596,6 +600,9 @@ function traceEnabled(raw) { * @param {string} [opts.tokenDecimalsOverride] what decimals() answers for * the stub token, in place of the value Blockscout reports for it. This is * the token that lies about its scale; read at request time. + * @param {string} [opts.tokenSymbolOverride] what the explorer reports as + * the stub token's symbol, in place of "E2E". This is the token whose + * symbol is markup; read at request time. * @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a * confirmed receipt instead of null, so a wait screen resolves. * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => @@ -674,14 +681,14 @@ async function installNetworkStubs(ctx, opts) { return jsonResponse(route, { items: opts.seedTokenTransfer && addr - ? tokenTransferItems(addr) + ? tokenTransferItems(addr, opts) : [], }); } if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) { return jsonResponse( route, - opts.seedTokenBalance ? tokenBalanceItems() : [], + opts.seedTokenBalance ? tokenBalanceItems(opts) : [], ); } for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) { diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 434f023..f467a65 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -2169,6 +2169,156 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30 await visible(env.page, "#view-address"); }); +// ------------------------------------------- hostile token symbol (#307) +// +// The reproduction from the issue, in the real browser against the real +// shipped manifest. A token symbol is whatever the contract's symbol() +// returns, the explorer passes it through, and the popup interpolated it +// into an innerHTML string — so a token with 1,000 holders airdropped to +// the victim could paint a full-viewport cross-origin iframe over the +// wallet's own UI, on the screens where the user types their password. +// +// The iframe count and the rendered text are asserted separately on +// purpose, and neither substitutes for the other. `frame-src 'none'` stops +// an injected frame LOADING; it does not stop the element existing, so a +// zero iframe count is a claim about the escaping and about nothing else. +// The literal capped text is the claim that the symbol was treated as a +// string all the way down. +// +// The iframe count is taken on the address screen before anything is +// clicked. That is where the injected frame lands first, and it covers the +// viewport: with the escaping removed, every later step fails as a click +// timeout ("'; + +// What a correctly escaped and capped render of it reads as: the first +// MAX_SYMBOL_LENGTH-1 characters and an ellipsis. Spelled out rather than +// imported, so a change to the cap has to be restated here deliberately +// instead of being absorbed by a shared constant. +const HOSTILE_SYMBOL_DISPLAYED = "'; + +describe("escapeHtml", () => { + test("escapes all five characters, quotes included", () => { + expect(escapeHtml("&<>\"'")).toBe("&<>"'"); + }); + + // The regression this function was rewritten for. The previous + // implementation round-tripped through a detached div's textContent, + // and an HTML text node serializes a quote as itself — so a value with + // a quote in it broke straight out of data-copy="..." and href="...". + test("escapes quotes, which the textContent round trip did not", () => { + expect(escapeHtml('a"b')).toBe("a"b"); + expect(escapeHtml("a'b")).toBe("a'b"); + }); + + test("does not double-escape an ampersand it just introduced", () => { + expect(escapeHtml("<")).toBe("&lt;"); + expect(escapeHtml("&")).toBe("&amp;"); + }); + + test("leaves a string with nothing to escape untouched", () => { + expect(escapeHtml("USDC")).toBe("USDC"); + expect(escapeHtml("")).toBe(""); + }); + + test("renders the hostile symbol inert", () => { + const out = escapeHtml(HOSTILE_SYMBOL); + expect(out).not.toContain("<"); + expect(out).not.toContain(">"); + expect(out).not.toContain('"'); + expect(out).toContain("<iframe"); + }); + + // A quoted attribute is broken out of by a quote, a bare one by a + // space; both are closed here. Asserted as a whole attribute rather + // than character by character, because it is the attribute that has to + // survive, not the escape table. + test("a value carrying a quote stays inside its attribute", () => { + const evil = '" onload="alert(1)'; + const attr = `data-copy="${escapeHtml(evil)}"`; + expect(attr).toBe('data-copy="" onload="alert(1)"'); + expect(attr.split('"').length - 1).toBe(2); + }); + + test("null and undefined render as nothing rather than as words", () => { + expect(escapeHtml(null)).toBe(""); + expect(escapeHtml(undefined)).toBe(""); + }); + + test("coerces a non-string without losing the escape", () => { + expect(escapeHtml(42)).toBe("42"); + expect(escapeHtml({ toString: () => "" })).toBe("<b>"); + }); +}); + +describe("displaySymbol", () => { + test("passes every symbol in the bundled list through unchanged", () => { + const { TOKENS } = require("../src/shared/tokenList"); + for (const t of TOKENS) { + expect([t.address, displaySymbol(t.symbol)]).toEqual([ + t.address, + t.symbol, + ]); + } + }); + + test("caps an over-long symbol and marks it as truncated", () => { + const long = "A".repeat(4096); + const out = displaySymbol(long); + expect(out.length).toBe(MAX_SYMBOL_LENGTH); + expect(out.endsWith("…")).toBe(true); + }); + + test("keeps a symbol of exactly the cap intact", () => { + const exact = "A".repeat(MAX_SYMBOL_LENGTH); + expect(displaySymbol(exact)).toBe(exact); + }); + + test("substitutes a placeholder for an absent symbol", () => { + expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL); + expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL); + expect(displaySymbol(undefined)).toBe(UNKNOWN_SYMBOL); + }); + + // The cap is a layout bound and nothing more: it must not be mistaken + // for the thing that makes a symbol safe to render. A short hostile + // symbol passes through it untouched, and is inert only because the + // caller escapes it afterwards. + test("does not sanitize — a short markup symbol survives it verbatim", () => { + expect(displaySymbol("")).toBe(""); + expect(escapeHtml(displaySymbol(""))).toBe( + "<img src=x>", + ); + }); +}); diff --git a/tests/manifest.test.js b/tests/manifest.test.js index dd94b4f..7ebc1da 100644 --- a/tests/manifest.test.js +++ b/tests/manifest.test.js @@ -13,6 +13,33 @@ // an exact match on the token set is what keeps the next edit from // smuggling one in alongside. // +// It is also the anti-regression check for #307. The policy used to declare +// script-src and object-src and nothing else, which left every directive +// that does not fall back to them — and, absent default-src, every one that +// does — wide open: a hostile ERC-20 symbol that reached innerHTML could +// load a full-viewport cross-origin iframe over the wallet's own UI. The +// escaping in src/shared/html.js is the primary fix; default-src is what +// stops the next escape that slips from reaching the network. +// +// Every directive below is pinned exactly, because each of the four +// loosenings is load-bearing and none of them may grow: +// +// style-src 'unsafe-inline' src/popup/index.html and the view helpers +// use style="..." attributes throughout, which +// CSP blocks without it. Chrome enforces this +// on attributes, not just