fix: render a hostile token symbol as text, and put a floor under the CSP (closes #307)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 1m11s
e2e / e2e-firefox (push) Successful in 22s

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 <iframe id="pwn"> in the popup DOM, intercepting pointer
events over the Back button.
This commit is contained in:
2026-08-20 11:34:41 +00:00
parent 59f68b8859
commit ada41bf5e1
24 changed files with 706 additions and 97 deletions

View File

@@ -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]) {

View File

@@ -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 ("<iframe id=\"pwn\"> intercepts pointer events") rather than as
// anything that names the defect.
// Verbatim from the issue's reproduction.
const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
// 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 = "<iframe id=" + "…";
// Everything the popup can say about an injected symbol, read out of the
// live DOM in one pass.
function hostileSymbolState(page, tokenAddress) {
return page.evaluate((addr) => {
const row = document.querySelector(
'#wallet-list [data-token="' + addr + '"]',
);
// balanceLine() emits <div data-token><span><span>SYMBOL</span>…
// so this is the span the symbol itself was written into.
const symbolEl = row && row.firstElementChild.firstElementChild;
return {
rowFound: !!row,
rowText: row ? row.innerText.trim() : "",
symbolText: symbolEl ? symbolEl.textContent : "",
// The symbol's own span must hold text and nothing else. An
// element child here is the injection, whether or not it
// happens to be an iframe.
symbolElementChildren: symbolEl
? symbolEl.querySelectorAll("*").length
: -1,
// The whole popup document, not just the row: an injected
// element positioned fixed can be anywhere in the tree.
iframes: document.querySelectorAll("iframe").length,
pwnPresent: !!document.getElementById("pwn"),
};
}, tokenAddress);
}
test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
console.log(
"# stub token symbol() now returns: " + JSON.stringify(HOSTILE_SYMBOL),
);
// Close and reopen so the refresh that runs on open fetches balances
// with the hostile symbol in them.
await reopenPopup(env, "#view-address");
await env.page.waitForFunction(
(addr) =>
!!document.querySelector(
'#address-balances [data-token="' + addr + '"]',
),
STUB_TOKEN.address,
{ timeout: 60000 },
);
const onAddress = await env.page.evaluate(() => ({
iframes: document.querySelectorAll("iframe").length,
pwnPresent: !!document.getElementById("pwn"),
}));
console.log("# address-detail iframes = " + onAddress.iframes);
assert(
onAddress.iframes === 0 && !onAddress.pwnPresent,
"the address screen contains " +
onAddress.iframes +
" iframe(s) after a hostile symbol rendered (#307)",
);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await visible(
env.page,
'#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
60000,
);
const st = await hostileSymbolState(env.page, STUB_TOKEN.address);
console.log(
"# iframes in the popup DOM = " +
st.iframes +
" | #pwn present = " +
st.pwnPresent +
" | symbol = " +
JSON.stringify(st.symbolText),
);
assert(st.rowFound, "the hostile token never rendered a row at all");
assert(
st.iframes === 0,
"the popup DOM contains " + st.iframes + " iframe(s) (#307)",
);
assert(!st.pwnPresent, "the injected #pwn element is in the popup DOM");
assert(
st.symbolElementChildren === 0,
"the symbol span grew " +
st.symbolElementChildren +
" element children out of a token symbol (#307)",
);
assert(
st.symbolText === HOSTILE_SYMBOL_DISPLAYED,
"the symbol did not render as the literal capped text " +
JSON.stringify(HOSTILE_SYMBOL_DISPLAYED) +
": " +
JSON.stringify(st.symbolText),
);
assert(
!st.rowText.includes("z-index"),
"the uncapped symbol reached the screen: " + JSON.stringify(st.rowText),
);
// Put the fixture back before the next test reads it, and let the
// stored balances be rewritten with the honest symbol.
env.routeOpts.tokenSymbolOverride = null;
await reopenPopup(env, "#view-main");
await env.page.waitForFunction(
(addr) => {
const row = document.querySelector(
'#wallet-list [data-token="' + addr + '"]',
);
return !!row && row.innerText.includes("E2E");
},
STUB_TOKEN.address,
{ timeout: 60000 },
);
});
// ------------------------------------------- dApp round trips (#183)
//
// The seam. Everything above drives the popup on its own; this section is
@@ -3303,6 +3453,9 @@ async function main() {
// something other than the value the same fixture reports through
// Blockscout. The token that lies about its scale (#305).
tokenDecimalsOverride: null,
// What the explorer reports as the stub token's symbol. The token
// whose symbol() returns markup (#307).
tokenSymbolOverride: null,
// Whether eth_getTransactionReceipt confirms a transaction rather than
// answering "not mined yet".
seedReceipt: false,