fix: render a hostile token symbol as text, and put a floor under the CSP (closes #307) #327

Merged
clawbot merged 1 commits from issue-307-escape-html into next 2026-08-20 13:47:29 +02:00
24 changed files with 706 additions and 97 deletions

View File

@@ -1464,10 +1464,44 @@ policy, but as of now there are none.
### Content Security Policy
Both manifests declare the same policy for extension pages
`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under
Both manifests declare the same policy for extension pages, as an object under
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as
a bare string in `manifest/firefox.json` (MV2).
a bare string in `manifest/firefox.json` (MV2):
```
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
style-src 'self' 'unsafe-inline'; img-src 'self' data:;
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
base-uri 'none'
```
`default-src 'self'` is the floor. Without it the policy governed script and
plugins only, and everything else — frames above all — was unrestricted, which
is what let an unescaped token symbol paint a cross-origin iframe over the
wallet's own UI. Escaping is the primary fix for that (see
`src/shared/html.js`); this is the second line, so an escape that does slip
cannot reach the network.
Four directives are looser than `'self'`, each for a reason that does not
generalise:
- `style-src 'unsafe-inline'``src/popup/index.html` and the view helpers set
presentation through `style="..."` attributes, which CSP blocks without this.
Chrome enforces `style-src` on attributes, not only on `<style>` blocks, and
Firefox has never implemented `style-src-attr`, so there is no narrower
spelling that works on both targets. It permits inline **style**; script stays
under `script-src`, which does not allow `'unsafe-inline'`.
- `img-src data:` — identicons are generated in the popup by
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
node over `http://127.0.0.1` is a supported configuration, which the Firefox
end-to-end suite depends on. The wallet's outbound traffic is constrained by
what it is written to contact (see External Communication), not by this
directive.
- `frame-src 'none'`, `form-action 'none'`, `base-uri 'none'` — named rather
than inherited. `form-action` and `base-uri` do not fall back to `default-src`
at all, so they would have stayed unrestricted; `frame-src 'none'` is what
refuses the framed-overlay attack outright.
`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly
build and a `wasm2js` translation of it in one file, tries WASM first, and
@@ -1485,9 +1519,10 @@ strings, not inline script, not remote script. Using it requires already
executing script in an extension page, which is complete compromise on its own.
`'unsafe-eval'` is a different proposition and is not granted.
The grant is pinned in both directions. `tests/manifest.test.js` asserts the
exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent
20x regression on the key derivation) and adding anything beyond it both fail
The policy is pinned in both directions. `tests/manifest.test.js` asserts the
exact directive set and the exact token set of each directive in both manifests,
so dropping `'wasm-unsafe-eval'` (a silent 20x regression on the key
derivation), dropping `default-src`, and adding anything anywhere all fail
`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM
backend, and `make test-e2e` compiles a WebAssembly module inside the real popup
under the real manifest.

22
TODO.md
View File

@@ -44,6 +44,28 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-20: A hostile ERC-20 symbol no longer renders as live HTML in the
popup ([#307](https://git.eeqj.de/sneak/AutistMask/issues/307)). A token
symbol is whatever the contract's `symbol()` returns, the block explorer
passes it through unfiltered, and `balanceLine()` interpolated it into an
`innerHTML` string — so a token with the 1,000 holders the spam filter asks
for, 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.
`escapeHtml` moved to `src/shared/html.js` as a pure string replace over `&`,
`<`, `>`, `"` and `'`: the old implementation round-tripped through a detached
element's `textContent`, which does not escape quotes, and it was already
being used inside `data-copy="..."`. Every interpolation into an `innerHTML`
string across `src/popup/views/` was audited, not just the reported one — the
transaction lists' direction label, the wallet name and ENS name in the Home
list, the `href` in the explorer link, and the confirmation screen's warning
line were all unescaped as well. Both manifests now declare
`default-src 'self'` with `frame-src 'none'`; the four directives that had to
stay looser than `'self'` are named and justified in the Content Security
Policy section of README.md, and `tests/manifest.test.js` pins the whole set
exactly. A display cap of 12 characters bounds the symbol, matching the bound
`lookupTokenInfo()` already applied on the contract-read path. Not repurposed
for any of this: `isSpoofedSymbol()`, which answers a different question and
would have been the wrong control.
- 2026-08-20: A page asking which chain the wallet is on is told the chain the
user is actually on ([#317](https://git.eeqj.de/sneak/AutistMask/issues/317)).
`eth_chainId` and `net_version` answered from `currentNetwork()`, which reads

View File

@@ -6,7 +6,7 @@
"permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
},
"action": {
"default_popup": "src/popup/index.html"

View File

@@ -4,7 +4,7 @@
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
"browser_action": {
"default_popup": "src/popup/index.html"
},

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, goBack } = require("./helpers");
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -13,7 +13,7 @@ function show() {
list.innerHTML = getTopTokens(25)
.map(
(t) =>
`<button class="common-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-address="${t.address}" data-symbol="${t.symbol}" data-decimals="${t.decimals}">${t.symbol}</button>`,
`<button class="common-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-address="${escapeHtml(t.address)}" data-symbol="${escapeHtml(t.symbol)}" data-decimals="${escapeHtml(t.decimals)}">${escapeHtml(t.symbol)}</button>`,
)
.join("");
list.querySelectorAll(".common-token").forEach((btn) => {

View File

@@ -6,6 +6,7 @@ const {
addressDotHtml,
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
@@ -221,10 +222,12 @@ function renderTransactions(txs) {
: tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
// The explorer's method name for a contract call, title-cased.
const dirLabel = escapeHtml(tx.directionLabel);
const sym = displaySymbol(tx.symbol);
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);

View File

@@ -9,6 +9,7 @@ const {
addressDotHtml,
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
balanceLine,
renderAddressHtml,
@@ -124,7 +125,11 @@ function show() {
currentSymbol = symbol;
$("address-token-title").textContent =
wallet.name + " \u2014 Address " + (ai + 1) + " \u2014 " + symbol;
wallet.name +
" \u2014 Address " +
(ai + 1) +
" \u2014 " +
displaySymbol(symbol);
// Blockie
const blockieEl = $("address-token-jazzicon");
@@ -174,7 +179,9 @@ function show() {
(knownToken && knownToken.symbol) ||
null;
const tokenName = rawName ? escapeHtml(rawName) : null;
const tokenSymbol = rawSymbol ? escapeHtml(rawSymbol) : null;
const tokenSymbol = rawSymbol
? escapeHtml(displaySymbol(rawSymbol))
: null;
const tokenDecimals =
tb && tb.decimals != null
? tb.decimals
@@ -288,10 +295,12 @@ function renderTransactions(txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets);
const dirLabel = tx.directionLabel;
// The explorer's method name for a contract call, title-cased.
const dirLabel = escapeHtml(tx.directionLabel);
const sym = displaySymbol(tx.symbol);
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
@@ -361,7 +370,7 @@ function init(_ctx) {
}
// Hide dropdown, show static token display
$("send-token").classList.add("hidden");
let staticHtml = `<div class="font-bold">${escapeHtml(currentSymbol)}</div>`;
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`;
if (tokenId !== "ETH") {
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
}

View File

@@ -10,6 +10,7 @@ const {
showView,
addressTitle,
escapeHtml,
displaySymbol,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -57,7 +58,7 @@ function restore() {
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function confirmAddressHtml(address, ensName, title) {
@@ -81,7 +82,11 @@ function show(txInfo) {
feeWei = null;
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
// The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const symbol = displaySymbol(rawSymbol);
// Transaction type
if (isErc20) {
@@ -123,7 +128,7 @@ function show(txInfo) {
// Amount (with inline USD)
const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(symbol);
const tokenPrice = getPrice(rawSymbol);
const amountNum = parseFloat(txInfo.amount);
const price = isErc20 ? tokenPrice : ethPrice;
const amountUsd = price ? amountNum * price : null;
@@ -156,7 +161,12 @@ function show(txInfo) {
warningsEl.innerHTML = localWarnings
.map(
(w) =>
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w.message}</div>`,
// Only the three hardcoded strings in
// src/shared/addressWarnings.js reach this today, but
// src/shared/etherscanLabels.js already builds a
// `warning` out of scraped explorer markup, so this is
// one wiring change away from carrying remote text.
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${escapeHtml(w.message)}</div>`,
)
.join("");
warningsEl.style.visibility = "visible";
@@ -206,7 +216,7 @@ function show(txInfo) {
// touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
const { canSend, codes } = validateTransfer({
isErc20,

View File

@@ -11,6 +11,7 @@ const {
$,
showView,
showFlash,
escapeHtml,
goBack,
renderAddressHtml,
attachCopyHandlers,
@@ -92,7 +93,7 @@ function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return "&nbsp;";
const line = formatAddressTotal(getAddressValue(addr));
const total = line
? `<div class="text-xs text-muted mt-1">${line}</div>`
? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>`
: "";
return (
`<p class="mb-1">This address holds a balance. Removing it does not ` +

View File

@@ -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) || "&nbsp;" : "&nbsp;";
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,

View File

@@ -8,6 +8,7 @@ const {
addressDotHtml,
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
@@ -109,10 +110,13 @@ function renderHomeTxList(ctx) {
: tx.direction === "sent" || tx.direction === "contract"
? tx.to
: tx.from;
const dirLabel = tx.directionLabel;
// directionLabel is the explorer's own method name for a contract
// call, title-cased — attacker-chosen for an attacker's contract.
const dirLabel = escapeHtml(tx.directionLabel);
const sym = displaySymbol(tx.symbol);
const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(tx.symbol);
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
@@ -226,7 +230,7 @@ function walletListHtml() {
const defect = walletDefect(wallet);
html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
// No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce
// under the standard path.
@@ -250,10 +254,13 @@ function walletListHtml() {
const titleBold = isActive ? "font-bold" : "";
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
if (addr.ensName) {
html += `<div class="text-xs font-bold flex items-center">${dot}${addr.ensName}</div>`;
// An ENS reverse record is whatever the name owner set it
// to; renderAddressHtml() escapes its own copy of this and
// this list was the one that did not.
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
}
html += `<div class="flex text-xs items-center justify-between">`;
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`;
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${escapeHtml(addr.address)}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr));

View File

@@ -5,6 +5,7 @@ const {
flashCopyFeedback,
formatAddressHtml,
addressTitle,
displaySymbol,
attachCopyHandlers,
goBack,
} = require("./helpers");
@@ -44,7 +45,7 @@ function show() {
}
warningEl.textContent =
"This is an ERC-20 token. Only send " +
symbol +
displaySymbol(symbol) +
" on " +
currentNetwork().name +
" to this address. Sending tokens on other networks will result in permanent loss.";

View File

@@ -4,6 +4,7 @@ const {
$,
showFlash,
addressTitle,
displaySymbol,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -131,7 +132,7 @@ function renderSendTokenSelect(addr) {
if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue;
const opt = document.createElement("option");
opt.value = t.address;
opt.textContent = t.symbol;
opt.textContent = displaySymbol(t.symbol);
sel.appendChild(opt);
}
}

View File

@@ -4,6 +4,7 @@ const {
updateDebugBanner,
showFlash,
escapeHtml,
displaySymbol,
flashCopyFeedback,
goBack,
pushCurrentView,
@@ -43,8 +44,11 @@ function renderSiteList(containerId, siteMap, stateKey) {
let html = "";
hostnames.forEach((hostname) => {
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span>${hostname}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${stateKey}" data-hostname="${hostname}">[x]</button>`;
// A hostname the URL parser produced cannot carry a delimiter, so
// this is escaped for the rule rather than for a known hole — the
// rule being that nothing reaches innerHTML unescaped.
html += `<span>${escapeHtml(hostname)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `</div>`;
});
container.innerHTML = html;
@@ -73,9 +77,10 @@ function renderTrackedTokens() {
}
let html = "";
state.trackedTokens.forEach((token, idx) => {
const sym = escapeHtml(displaySymbol(token.symbol));
const label = token.name
? escapeHtml(token.name) + " (" + escapeHtml(token.symbol) + ")"
: escapeHtml(token.symbol);
? escapeHtml(token.name) + " (" + sym + ")"
: sym;
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span>${label}</span>`;
html += `<button class="btn-remove-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, goBack } = require("./helpers");
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
@@ -26,11 +26,11 @@ function renderTop10() {
: "border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs";
return (
`<button class="settings-addtoken-quick ${cls}"` +
` data-address="${t.address}"` +
` data-symbol="${t.symbol}"` +
` data-decimals="${t.decimals}"` +
` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${t.symbol}</button>`
` data-address="${escapeHtml(t.address)}"` +
` data-symbol="${escapeHtml(t.symbol)}"` +
` data-decimals="${escapeHtml(t.decimals)}"` +
` data-name="${escapeHtml(t.name || "")}"` +
`${tracked ? " disabled" : ""}>${escapeHtml(t.symbol)}</button>`
);
})
.join("");
@@ -62,11 +62,11 @@ function renderDropdown() {
const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
html +=
`<option value="${t.address}"` +
` data-symbol="${t.symbol}"` +
` data-decimals="${t.decimals}"` +
` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${label}</option>`;
`<option value="${escapeHtml(t.address)}"` +
` data-symbol="${escapeHtml(t.symbol)}"` +
` data-decimals="${escapeHtml(t.decimals)}"` +
` data-name="${escapeHtml(t.name || "")}"` +
`${tracked ? " disabled" : ""}>${escapeHtml(label)}</option>`;
}
sel.innerHTML = html;
}

View File

@@ -15,9 +15,11 @@ const {
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
explorerUrl,
displaySymbol,
goBack,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const { state } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
@@ -44,7 +46,7 @@ function getTransactionType(tx) {
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function txAddressHtml(address, ensName, title) {
@@ -56,7 +58,7 @@ function txAddressHtml(address, ensName, title) {
}
function txHashHtml(hash) {
const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
const link = explorerUrl("tx", hash);
const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink;
}
@@ -101,9 +103,10 @@ function render() {
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
// Exact amount (full precision, copyable)
const detailSym = displaySymbol(tx.symbol);
const exactStr = tx.exactValue
? tx.exactValue + " " + tx.symbol
: tx.directionLabel + " " + tx.symbol;
? tx.exactValue + " " + detailSym
: tx.directionLabel + " " + detailSym;
$("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold");
// Native quantity (raw integer, copyable)
@@ -133,7 +136,7 @@ function render() {
if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = `${currentNetwork().explorerUrl}/token/${tx.contractAddress}`;
const link = explorerUrl("token", tx.contractAddress);
tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") +
@@ -185,7 +188,7 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData) {
// Block number
if (txData.block_number != null) {
const blockLink = `${currentNetwork().explorerUrl}/block/${txData.block_number}`;
const blockLink = explorerUrl("block", String(txData.block_number));
const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
@@ -309,7 +312,7 @@ async function loadFullTxDetails(txHash, toAddress) {
// Token entry: show symbol on its own line, then address via shared renderer
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
}
detailsHtml += renderAddressHtml(d.address);
} else if (d.address) {

View File

@@ -9,10 +9,12 @@ const {
attachCopyHandlers,
copyableHtml,
etherscanLinkHtml,
explorerUrl,
displaySymbol,
clearViewStack,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, currentNetwork } = require("../../shared/state");
const { state } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -62,13 +64,13 @@ function toAddressHtml(address) {
}
function txHashHtml(hash) {
const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
const link = explorerUrl("tx", hash);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
}
function blockNumberHtml(blockNumber) {
const num = String(blockNumber);
const link = `${currentNetwork().explorerUrl}/block/${num}`;
const link = explorerUrl("block", num);
return copyableHtml(num) + etherscanLinkHtml(link);
}
@@ -80,7 +82,10 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait();
const id = waitId;
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
const symbol =
txInfo.token === "ETH"
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
@@ -211,7 +216,10 @@ function restoreWait() {
function showSuccess(txInfo, txHash, blockNumber) {
endWait();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
const symbol =
txInfo.token === "ETH"
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = {
amount: txInfo.amount,
symbol: symbol,
@@ -299,7 +307,10 @@ function renderSuccess() {
function showError(txInfo, txHash, message) {
endWait();
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
const symbol =
txInfo.token === "ETH"
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = {
amount: txInfo.amount,
symbol: symbol,

41
src/shared/html.js Normal file
View File

@@ -0,0 +1,41 @@
// HTML escaping for values interpolated into an innerHTML string.
//
// Every view in src/popup/views/ builds markup by string concatenation, so
// this is the only thing standing between a value the wallet did not author
// and the extension's own DOM. The values that reach it are attacker
// controlled by design: an ERC-20's symbol() and name() are whatever the
// contract chooses to return, an ENS name is whatever the resolver returns,
// and both arrive through the block explorer with no schema.
//
// It escapes both quote characters as well as the tag delimiters, because
// the popup interpolates into attribute values as well as into element
// text — copyableHtml() writes data-copy="..." and etherscanLinkHtml()
// writes href="...". A `<`/`>`-only escape leaves an unquoted-attribute
// break-out intact, and the round trip through a detached element's
// textContent that used to implement this was exactly that escape: the
// HTML serializer only escapes `&`, `<`, `>` and U+00A0 in a text node,
// since a text node has no idea it is about to be pasted inside quotes.
//
// Deliberately a pure string function with no DOM dependency: it is called
// on every rendered row, it is unit-testable without a document, and it
// cannot be affected by the state of a document that an attacker-supplied
// string has already been written into.
const HTML_ESCAPES = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
// `&` is escaped first by virtue of being in the same pass: a sequential
// replace would re-escape the ampersands it had just introduced.
function escapeHtml(s) {
if (s === null || s === undefined) return "";
return String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
}
module.exports = {
escapeHtml,
};

View File

@@ -0,0 +1,43 @@
// The length bound on a token symbol as displayed.
//
// A symbol is whatever an ERC-20's symbol() returns and the wallet fetches
// it from the block explorer, which imposes no length: src/shared/balances.js
// takes `item.token.symbol` as given. A kilobyte-long symbol is a real
// return value, and rendering it pushes every amount off the row, scrolls
// the balance list past the screen, and hides the figures the user is there
// to read.
//
// This is a layout bound, not a security control. Escaping is what makes a
// hostile symbol inert (see src/shared/html.js), and isSpoofedSymbol() is
// what catches one impersonating a known ticker; neither job belongs here
// and neither is done here. Truncating an unescaped symbol would still be
// an injection, just a shorter one.
//
// 12 characters, which is the bound lookupTokenInfo() in
// src/shared/balances.js already applies when it stores a symbol read
// straight off a contract; the explorer path was the one with no bound at
// all. The longest symbol across the 512 entries of the bundled list is 10
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
// truncated. The ellipsis is what tells the user the name they are looking
// at is not the whole name — worth knowing before they send to it.
const MAX_SYMBOL_LENGTH = 12;
// The placeholder for a token whose symbol the explorer did not report.
// balances.js already substitutes this; repeated here so a symbol that
// arrives empty from anywhere else displays the same way rather than as a
// blank gap in the row.
const UNKNOWN_SYMBOL = "???";
function displaySymbol(symbol) {
const s = symbol === null || symbol === undefined ? "" : String(symbol);
if (s.length === 0) return UNKNOWN_SYMBOL;
if (s.length <= MAX_SYMBOL_LENGTH) return s;
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
}
module.exports = {
displaySymbol,
MAX_SYMBOL_LENGTH,
UNKNOWN_SYMBOL,
};

View File

@@ -0,0 +1,69 @@
// 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("&lt;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="&quot; onclick=&quot;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"');
});
});

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,

110
tests/htmlEscape.test.js Normal file
View File

@@ -0,0 +1,110 @@
// The escape every view depends on, and the length bound on a displayed
// token symbol. Both were added for #307, where a token whose symbol()
// returned an <iframe> tag rendered that iframe inside the popup.
const { escapeHtml } = require("../src/shared/html");
const {
displaySymbol,
MAX_SYMBOL_LENGTH,
UNKNOWN_SYMBOL,
} = 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("escapeHtml", () => {
test("escapes all five characters, quotes included", () => {
expect(escapeHtml("&<>\"'")).toBe("&amp;&lt;&gt;&quot;&#39;");
});
// 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&quot;b");
expect(escapeHtml("a'b")).toBe("a&#39;b");
});
test("does not double-escape an ampersand it just introduced", () => {
expect(escapeHtml("&lt;")).toBe("&amp;lt;");
expect(escapeHtml("&amp;")).toBe("&amp;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("&lt;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="&quot; onload=&quot;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: () => "<b>" })).toBe("&lt;b&gt;");
});
});
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("<img src=x>")).toBe("<img src=x>");
expect(escapeHtml(displaySymbol("<img src=x>"))).toBe(
"&lt;img src=x&gt;",
);
});
});

View File

@@ -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 <style> blocks, and
// Firefox has never implemented style-src-attr,
// so there is no narrower spelling available.
// img-src data: blockies are data: PNGs assigned to img.src.
// connect-src https: http: the RPC endpoint is user-configurable, and a
// local node over http://127.0.0.1 is a
// supported configuration — the Firefox e2e
// suite runs on exactly that.
// frame-src/form-action/base-uri named rather than inherited: form-action
// and base-uri do not fall back to default-src
// at all, and frame-src 'none' is what kills
// the reported attack outright.
//
// build.js copies these files to dist/<target>/manifest.json verbatim, so
// what is asserted here is what ships.
@@ -21,8 +48,22 @@ const path = require("path");
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
const EXPECTED_SCRIPT_SRC = ["'self'", "'wasm-unsafe-eval'"];
const EXPECTED_OBJECT_SRC = ["'self'"];
const EXPECTED_DIRECTIVES = {
"default-src": ["'self'"],
"script-src": ["'self'", "'wasm-unsafe-eval'"],
"object-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:"],
"connect-src": ["'self'", "http:", "https:"],
"frame-src": ["'none'"],
"form-action": ["'none'"],
"base-uri": ["'none'"],
};
// Directives that fetch script. Nothing that can execute code may name a
// remote source, an eval form, or an inline form; 'wasm-unsafe-eval' is the
// single deliberate exception and it is pinned above.
const SCRIPT_DIRECTIVES = ["default-src", "script-src", "object-src"];
const FORBIDDEN_SOURCES = [
"'unsafe-eval'",
@@ -53,26 +94,31 @@ function parseCsp(policy) {
function assertPolicy(policy) {
const directives = parseCsp(policy);
expect(Object.keys(directives).sort()).toEqual([
"object-src",
"script-src",
// Exact, in both directions: a directive that appears here and not in
// EXPECTED_DIRECTIVES is an unreviewed addition, and one that
// disappears silently reopens whatever it was closing.
expect(Object.keys(directives).sort()).toEqual(
Object.keys(EXPECTED_DIRECTIVES).sort(),
);
for (const [name, sources] of Object.entries(EXPECTED_DIRECTIVES)) {
expect([name, directives[name].slice().sort()]).toEqual([
name,
sources.slice().sort(),
]);
expect(directives["script-src"].slice().sort()).toEqual(
EXPECTED_SCRIPT_SRC,
);
expect(directives["object-src"].slice().sort()).toEqual(
EXPECTED_OBJECT_SRC,
);
}
for (const name of SCRIPT_DIRECTIVES) {
for (const source of FORBIDDEN_SOURCES) {
expect(directives["script-src"]).not.toContain(source);
expect(directives["object-src"]).not.toContain(source);
expect(name + " " + directives[name].join(" ")).not.toContain(
" " + source,
);
}
}
}
describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs.
test("chrome MV3 allows WASM and nothing else beyond 'self'", () => {
test("chrome MV3 ships the pinned policy, default-src included", () => {
const csp = readManifest("chrome").content_security_policy;
expect(typeof csp).toBe("object");
expect(Object.keys(csp)).toEqual(["extension_pages"]);
@@ -87,7 +133,7 @@ describe("shipped Content Security Policy", () => {
// Firefox before 106 rejects an MV2 policy string that omits
// object-src and falls back to its own default, discarding everything
// declared here. Same policy as Chrome, different manifest shape.
test("firefox MV2 allows WASM and nothing else beyond 'self'", () => {
test("firefox MV2 ships the pinned policy, default-src included", () => {
const csp = readManifest("firefox").content_security_policy;
expect(typeof csp).toBe("string");
assertPolicy(csp);