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:
@@ -1,8 +1,22 @@
|
||||
// Shared DOM helpers used by all views.
|
||||
//
|
||||
// Escaping rule for every view in this directory, since they all build
|
||||
// markup by concatenation: any VALUE interpolated into an innerHTML string
|
||||
// goes through escapeHtml(), whatever its provenance looks like today. The
|
||||
// only interpolations left bare are markup FRAGMENTS this code just built
|
||||
// (a rendered dot, an icon, a composed row), which escaping would turn into
|
||||
// visible angle brackets, and locally computed numbers and loop indices.
|
||||
// The distinction is meant to be greppable: an unescaped `${` next to a
|
||||
// name that reads like data is a defect.
|
||||
|
||||
// escapeHtml lives in src/shared/html.js, where the escape and the
|
||||
// reasoning behind it are; it is re-exported below so views keep importing
|
||||
// it from here.
|
||||
const { escapeHtml } = require("../../shared/html");
|
||||
const { isDebug } = require("../../shared/log");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { displaySymbol } = require("../../shared/symbolDisplay");
|
||||
const { markViewRendered } = require("../viewRouter");
|
||||
|
||||
// When views are added, removed, or transitions between them change,
|
||||
@@ -177,17 +191,26 @@ function showFlash(msg, duration = 2000) {
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// One row of the balance list: symbol, quantity, fiat value.
|
||||
//
|
||||
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
||||
// so it is attacker-chosen markup until it has been through escapeHtml, and
|
||||
// attacker-chosen length until it has been through displaySymbol. This is
|
||||
// the row that issue #307 was reported against: every screen that lists a
|
||||
// holding renders through here.
|
||||
function balanceLine(symbol, amount, price, tokenId) {
|
||||
const qty = amount.toFixed(4);
|
||||
const usd = price ? formatUsd(amount * price) || " " : " ";
|
||||
const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
|
||||
// tokenId is a contract address out of the same explorer JSON, and it
|
||||
// lands inside a quoted attribute.
|
||||
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
|
||||
const clickClass = tokenId
|
||||
? " cursor-pointer hover:bg-hover balance-row"
|
||||
: "";
|
||||
return (
|
||||
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
|
||||
`<span class="flex justify-between" style="width:42ch;max-width:100%">` +
|
||||
`<span>${symbol}</span>` +
|
||||
`<span>${escapeHtml(displaySymbol(symbol))}</span>` +
|
||||
`<span>${qty}</span>` +
|
||||
`</span>` +
|
||||
`<span class="text-right text-muted flex-1">${usd}</span>` +
|
||||
@@ -289,12 +312,6 @@ function addressDotHtml(address) {
|
||||
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Look up an address across all wallets and return its title
|
||||
// (e.g. "Address 1.2") or null if it's not one of ours.
|
||||
function addressTitle(address, wallets) {
|
||||
@@ -382,13 +399,26 @@ const EXT_ICON =
|
||||
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
||||
`</svg></span>`;
|
||||
|
||||
function etherscanAddressUrl(address) {
|
||||
return `${currentNetwork().explorerUrl}/address/${address}`;
|
||||
// Block-explorer URLs. The origin is a per-network constant from
|
||||
// src/shared/networks.js; only the path segment is data, and it comes out
|
||||
// of explorer JSON (a transaction's from/to, a token's address_hash), which
|
||||
// nothing upstream validates as hex. percent-encoding it keeps a segment
|
||||
// that contains a slash, a query or a fragment from re-pointing the link
|
||||
// somewhere else in the explorer.
|
||||
function explorerUrl(kind, value) {
|
||||
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
function etherscanAddressUrl(address) {
|
||||
return explorerUrl("address", address);
|
||||
}
|
||||
|
||||
// The URL still has to be escaped on the way into href="...": encoding
|
||||
// governs what the URL means, escaping governs whether it stays inside the
|
||||
// attribute.
|
||||
function etherscanLinkHtml(url) {
|
||||
return (
|
||||
`<a href="${url}" target="_blank" rel="noopener" ` +
|
||||
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" ` +
|
||||
`class="inline-flex items-center">${EXT_ICON}</a>`
|
||||
);
|
||||
}
|
||||
@@ -492,6 +522,7 @@ module.exports = {
|
||||
addressColor,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
addressTitle,
|
||||
formatAddressHtml,
|
||||
renderAddressHtml,
|
||||
@@ -499,6 +530,7 @@ module.exports = {
|
||||
attachCopyHandlers,
|
||||
etherscanAddressUrl,
|
||||
etherscanLinkHtml,
|
||||
explorerUrl,
|
||||
EXT_ICON,
|
||||
truncateMiddle,
|
||||
isoDate,
|
||||
|
||||
Reference in New Issue
Block a user