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.
70 lines
2.8 KiB
JavaScript
70 lines
2.8 KiB
JavaScript
// balanceLine() is the row that issue #307 was reported against: every
|
|
// screen that lists a holding renders through it, and the symbol it renders
|
|
// is whatever an ERC-20's symbol() returned. This asserts against the
|
|
// string it emits, which is what gets assigned to innerHTML.
|
|
//
|
|
// The browser half of the same claim — that a real Chrome renders that
|
|
// string as text and puts no iframe in the popup DOM — is in
|
|
// tests/e2e/run.js. This half runs inside the 20-second make test cap.
|
|
|
|
"use strict";
|
|
|
|
// helpers.js reaches for both at module scope through the modules it pulls
|
|
// in. Neither is exercised by anything asserted here.
|
|
global.chrome = {
|
|
storage: {
|
|
local: {
|
|
get: () => Promise.resolve({}),
|
|
set: () => Promise.resolve(),
|
|
},
|
|
},
|
|
runtime: { sendMessage: () => {} },
|
|
};
|
|
global.document = {
|
|
getElementById: () => null,
|
|
createElement: () => ({ style: {}, classList: { toggle() {} } }),
|
|
body: { prepend: () => {} },
|
|
addEventListener: () => {},
|
|
};
|
|
|
|
const { balanceLine } = require("../src/popup/views/helpers");
|
|
const { MAX_SYMBOL_LENGTH } = require("../src/shared/symbolDisplay");
|
|
|
|
// The payload from the issue's reproduction, verbatim.
|
|
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>';
|
|
|
|
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("<iframe");
|
|
expect(html).toContain("<iframe");
|
|
});
|
|
|
|
test("caps the symbol before rendering it", () => {
|
|
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("<span>USDC</span>");
|
|
expect(html).toContain("<span>1.5000</span>");
|
|
expect(html).toContain('data-token="0xabc"');
|
|
});
|
|
});
|