harden: escape every interpolation into popup innerHTML, and add default-src to both manifests (closes #307)
A hostile ERC-20's symbol() reached an innerHTML string unescaped, and neither manifest declared default-src, so an attacker deploying a token with 1,000+ holders and airdropping one unit could render a full-viewport cross-origin iframe over the wallet's own UI, on screens where the user types their password. escapeHtml is now a pure string replace over & < > " ' — the old version round-tripped through textContent, which escapes neither quote, while already being used inside data-copy="...". All 19 files in src/popup/views/ were audited: beyond the reported symbol site, the explorer-supplied directionLabel in all three transaction lists, wallet.name, addr.ensName, the blockie data: URIs and two ad-hoc quote-only escapes were also unescaped. Explorer URLs now go through one helper that percent-encodes the path segment. Both manifests add default-src 'self', frame-src 'none', form-action 'none' and base-uri 'none'. Three loosenings are pinned in tests/manifest.test.js and justified in README.md: style-src 'unsafe-inline' (39 static style attributes; Firefox implements neither style-src-attr nor 'unsafe-hashes'), img-src data: (blockies), connect-src https: http: (user-configurable RPC). Note frame-src 'none' blocks a frame loading, not the element existing, so the zero-iframe assertion is a claim about the escaping alone; the test asserts the element count and the literal rendered text separately, taking the count before any click an overlay could intercept. Verified: make check 39 suites / 811 tests, test-e2e 55/55 including the WebAssembly-under-CSP assertion, test-e2e-firefox 8/8, zero CSP violations asserted rather than merely unobserved. Reverting only balanceLine's interpolation reproduces the attack as 2 iframes on the address screen.
This commit was merged in pull request #327.
This commit is contained in:
153
tests/e2e/run.js
153
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 ("<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,
|
||||
|
||||
Reference in New Issue
Block a user