Compare commits

..

3 Commits

Author SHA1 Message Date
ada41bf5e1 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.
2026-08-20 11:34:41 +00:00
59f68b8859 fix: answer eth_chainId and net_version from loaded state (closes #317)
All checks were successful
check / check (push) Successful in 29s
e2e / e2e-chrome (push) Successful in 1m9s
e2e / e2e-firefox (push) Successful in 21s
Both methods answered from the module-level state singleton, which the MV3
worker never populates, so a cold worker reported mainnet 0x1 to a page whose
user was on Sepolia.

They now answer from getState(), the per-call detached storage read the other
read handlers already use. An earlier revision of this fix used loadState()
instead and was rejected in review: it replaces the whole singleton, and these
methods are page-callable with no connection gate (inpage.js sends eth_chainId
on every page load), so a load landing inside backgroundRefresh()'s network
round trip detached the address objects being mutated in place — persisting
pre-refresh balances while still stamping lastBalanceRefresh, letting a polling
page suppress background refreshes indefinitely.

The test stub now structured-clones on get and set, as chrome.storage.local
does. The aliasing stub it replaces was independently measured to hide this
defect class entirely: with the aliasing get restored and the defective handler
in place, the suite passes 794/794.

Verified failing first three ways: a plain singleton read fails the three
cold-worker cases; the rejected loadState() revision fails only the new
mid-refresh case ("1.5" expected, "0" received); moving saveState() ahead of
refreshBalances() fails that case and only it.
2026-08-20 13:11:48 +02:00
50078b3566 fix: resolve approval-screen token decimals, and refuse to format an unknown scale (closes #306)
All checks were successful
check / check (push) Successful in 34s
e2e / e2e-chrome (push) Successful in 1m13s
e2e / e2e-firefox (push) Successful in 28s
decodeCalldata consulted only the 512-entry bundled list and defaulted to 18
decimals, so a transfer of 5,000 units of a 6-decimal token rendered
"Amount 0.0000" and the user confirmed a drain reading zero. The same
understatement applied to approve, where an unbounded allowance also rendered
0.0000.

Decimals now resolve from the bundled list, then trackedTokens, then the
address's explorer-reported entry, with uint8 validation and a refusal when
sources for one contract disagree. When no source knows the scale, no
formatUnits call is reached at all: the line renders raw base units with an
explicit "decimals unknown" warning, and the same string reaches
pendingTxDetails.amount so the status screens carry no formatted figure either.

Verified failing first two independent ways: restoring the old
`token ? token.decimals : 18` fails 6 of 15 new tests with the unknown case
reporting "0.0000"; making the resolver return 18 rather than null on the
unknown path fails a different 6, spanning resolver and render levels.
2026-08-20 12:57:38 +02:00
29 changed files with 1181 additions and 139 deletions

View File

@@ -1464,10 +1464,44 @@ policy, but as of now there are none.
### Content Security Policy ### Content Security Policy
Both manifests declare the same policy for extension pages Both manifests declare the same policy for extension pages, as an object under
`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as `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 `'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 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. executing script in an extension page, which is complete compromise on its own.
`'unsafe-eval'` is a different proposition and is not granted. `'unsafe-eval'` is a different proposition and is not granted.
The grant is pinned in both directions. `tests/manifest.test.js` asserts the The policy is pinned in both directions. `tests/manifest.test.js` asserts the
exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent exact directive set and the exact token set of each directive in both manifests,
20x regression on the key derivation) and adding anything beyond it both fail 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 `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 backend, and `make test-e2e` compiles a WebAssembly module inside the real popup
under the real manifest. under the real manifest.

53
TODO.md
View File

@@ -44,21 +44,60 @@ but the review is broader than any of them.
# Completed Steps # 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 - 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)). user is actually on ([#317](https://git.eeqj.de/sneak/AutistMask/issues/317)).
`eth_chainId` and `net_version` answered from `currentNetwork()`, which reads `eth_chainId` and `net_version` answered from `currentNetwork()`, which reads
the module-level `state` singleton that nothing populates at module scope, so the module-level `state` singleton that nothing populates at module scope, so
a service worker revived by the page's own message answered out of a service worker revived by the page's own message answered out of
`DEFAULT_STATE` and reported mainnet `0x1`/`1` to a user on Sepolia — a dApp `DEFAULT_STATE` and reported mainnet `0x1`/`1` to a user on Sepolia — a dApp
building its interaction for the wrong chain. Both now `await loadState()` building its interaction for the wrong chain. Both now answer from
first, under one load covering the pair. The read side of the background was `getState()`, the per-call detached storage read the other read handlers use,
audited with it: the remaining singleton reads are the chain switch, the rather than from the singleton: these two are reachable by any page on every
transaction verification path and `backgroundRefresh`, which each already provider init, and mutating the shared singleton on that path would detach the
load, and everything else answers from storage per call through `getState()`. wallet objects an in-flight `backgroundRefresh()` is mutating. The read side
One stale read is left named but unfixed, outside this issue's scope: of the background was audited with it: the remaining singleton reads are the
`handleSendTransaction` builds its provider with no network name, so chain switch, the transaction verification path and `backgroundRefresh`, which
each already load, and everything else answers from storage per call through
`getState()`. One stale read is left named but unfixed, outside this issue's
scope: `handleSendTransaction` builds its provider with no network name, so
`getProvider()` falls back to the same unloaded singleton for ethers' static `getProvider()` falls back to the same unloaded singleton for ethers' static
network hint. network hint.
- 2026-08-20: The dApp approval screen no longer shows a token transfer it
cannot scale as `0.0000`
([#306](https://git.eeqj.de/sneak/AutistMask/issues/306)). `decodeCalldata`
read decimals from the 512-entry bundled token list alone and fell back to 18,
so every token outside it — most of them, including anything the user added by
contract address — was displayed at the wrong scale: a `transfer` of 5,000
units of a 6-decimal token read as `0.0000`, and a user who reads zero
confirms the drain. The new `src/shared/approvalAmount.js` resolves the scale
from the bundled list, then `state.trackedTokens`, then the decimals the block
explorer already reported in `addr.tokenBalances`, and refuses one the
explorer's own entries disagree about. Where no source knows it, the amount
line is not formatted at all: it shows the base-unit integer and states that
the scale is unknown, for `approve` as well as `transfer`. An unbounded
allowance still reads `Unlimited`, which needs no scale.
- 2026-08-20: A web page can no longer switch the wallet's chain, and switching - 2026-08-20: A web page can no longer switch the wallet's chain, and switching
no longer destroys the user's endpoints no longer destroys the user's endpoints
([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)). ([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)).

View File

@@ -6,7 +6,7 @@
"permissions": ["storage", "activeTab", "alarms"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"content_security_policy": { "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": { "action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"

View File

@@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"], "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": { "browser_action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"
}, },

View File

@@ -3,7 +3,11 @@
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint. // non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const { DEFAULT_RPC_URL } = require("../shared/constants"); const { DEFAULT_RPC_URL } = require("../shared/constants");
const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks"); const {
SUPPORTED_CHAIN_IDS,
networkById,
networkByChainId,
} = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch"); const { onChainSwitch } = require("../shared/chainSwitch");
const { const {
state, state,
@@ -663,20 +667,27 @@ async function handleRpc(method, params, origin) {
return { result: [] }; return { result: [] };
} }
// Both answer from currentNetwork(), which reads the module-level state // Both answered from currentNetwork(), which reads the module-level state
// singleton, and nothing populates that at module scope. A worker revived // singleton, and nothing populates that at module scope. A worker revived
// by the page's own message therefore held DEFAULT_STATE and told a page // by the page's own message therefore held DEFAULT_STATE and told a page
// it was on mainnet while the user was on Sepolia // it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317). One load covers both: // (https://git.eeqj.de/sneak/AutistMask/issues/317).
// they are the same read of the same value, and the switch handler below //
// and the transaction path have the same await for the same reason. // Answered from getState() rather than by loading the singleton. Any page
// reaches these two — neither is gated on a connection, and the injected
// provider sends eth_chainId on every page load — and loadState() replaces
// state.wallets wholesale, which would detach the address objects an
// in-flight backgroundRefresh() is mutating across its network round trip,
// so its saveState() would persist the pre-refresh balances while still
// stamping lastBalanceRefresh. getState() is the detached per-call storage
// read the other read handlers here already use.
// networkById(undefined) falls back to mainnet, matching the default for a
// profile with no stored networkId.
if (method === "eth_chainId" || method === "net_version") { if (method === "eth_chainId" || method === "net_version") {
await loadState(); const s = await getState();
const net = networkById(s.networkId);
return { return {
result: result: method === "eth_chainId" ? net.chainId : net.networkVersion,
method === "eth_chainId"
? currentNetwork().chainId
: currentNetwork().networkVersion,
}; };
} }

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 { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -13,7 +13,7 @@ function show() {
list.innerHTML = getTopTokens(25) list.innerHTML = getTopTokens(25)
.map( .map(
(t) => (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(""); .join("");
list.querySelectorAll(".common-token").forEach((btn) => { list.querySelectorAll(".common-token").forEach((btn) => {

View File

@@ -6,6 +6,7 @@ const {
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
truncateMiddle, truncateMiddle,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -221,10 +222,12 @@ function renderTransactions(txs) {
: tx.from; : tx.from;
const ensName = ensNameMap.get(counterparty) || null; const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets); 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 const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol) ? escapeHtml(tx.value + " " + sym)
: escapeHtml(tx.symbol); : escapeHtml(sym);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr); title || ensName || truncateMiddle(counterparty, maxAddr);

View File

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

View File

@@ -21,6 +21,10 @@ const {
const { getPrice, formatUsd } = require("../../shared/prices"); const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("../../shared/approvalAmount");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
@@ -43,6 +47,23 @@ function formatTxValue(val) {
return parts[0] + "." + dec; return parts[0] + "." + dec;
} }
// The amount line for a decoded ERC-20 call. With a known scale it is the
// token quantity; with `decimals` null it is the base-unit integer with the
// unknown scale stated, because formatting it with an assumed scale is what
// showed a 5,000-token transfer as `0.0000`. `raw` is what the status screens
// carry, `display` is what the approval screen shows.
function tokenAmountText(rawAmount, decimals, symbol) {
if (decimals === null) {
const unknown = unknownDecimalsAmount(rawAmount);
return { raw: unknown, display: unknown };
}
const formatted = formatTxValue(formatUnits(rawAmount, decimals));
return {
raw: formatted,
display: formatted + (symbol ? " " + symbol : ""),
};
}
function tokenLabel(address) { function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase()); const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null; return t ? t.symbol : null;
@@ -59,7 +80,15 @@ function decodeCalldata(data, toAddress) {
if (parsed) { if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase()); const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null; const tokenSymbol = token ? token.symbol : null;
const tokenDecimals = token ? token.decimals : 18; // null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the
// guess it is the wrong number in the direction that reads as
// zero. See tokenAmountText().
const tokenDecimals = resolveTokenDecimals(toAddress, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
const contractLabel = tokenSymbol const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")" ? tokenSymbol + " (" + toAddress + ")"
: toAddress; : toAddress;
@@ -71,12 +100,11 @@ function decodeCalldata(data, toAddress) {
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
); );
const isUnlimited = rawAmount === maxUint; const isUnlimited = rawAmount === maxUint;
const amountRaw = isUnlimited // An unbounded allowance needs no scale to describe, so it is
? "Unlimited" // still named rather than refused.
: formatTxValue(formatUnits(rawAmount, tokenDecimals)); const amount = isUnlimited
const amountStr = isUnlimited ? { raw: "Unlimited", display: "Unlimited" }
? "Unlimited" : tokenAmountText(rawAmount, tokenDecimals, tokenSymbol);
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return { return {
name: "Token Approval", name: "Token Approval",
@@ -97,8 +125,8 @@ function decodeCalldata(data, toAddress) {
}, },
{ {
label: "Amount", label: "Amount",
value: amountStr, value: amount.display,
rawValue: amountRaw, rawValue: amount.raw,
}, },
], ],
}; };
@@ -107,11 +135,11 @@ function decodeCalldata(data, toAddress) {
if (parsed.name === "transfer") { if (parsed.name === "transfer") {
const to = parsed.args[0]; const to = parsed.args[0];
const rawAmount = parsed.args[1]; const rawAmount = parsed.args[1];
const amountRaw = formatTxValue( const amount = tokenAmountText(
formatUnits(rawAmount, tokenDecimals), rawAmount,
tokenDecimals,
tokenSymbol,
); );
const amountStr =
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return { return {
name: "Token Transfer", name: "Token Transfer",
@@ -128,8 +156,8 @@ function decodeCalldata(data, toAddress) {
{ label: "Recipient", value: to, address: to }, { label: "Recipient", value: to, address: to },
{ {
label: "Amount", label: "Amount",
value: amountStr, value: amount.display,
rawValue: amountRaw, rawValue: amount.raw,
}, },
], ],
}; };

View File

@@ -10,6 +10,7 @@ const {
showView, showView,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -57,7 +58,7 @@ function restore() {
function blockieHtml(address) { function blockieHtml(address) {
const src = makeBlockie(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) { function confirmAddressHtml(address, ensName, title) {
@@ -81,7 +82,11 @@ function show(txInfo) {
feeWei = null; feeWei = null;
const isErc20 = txInfo.token !== "ETH"; 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 // Transaction type
if (isErc20) { if (isErc20) {
@@ -123,7 +128,7 @@ function show(txInfo) {
// Amount (with inline USD) // Amount (with inline USD)
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(symbol); const tokenPrice = getPrice(rawSymbol);
const amountNum = parseFloat(txInfo.amount); const amountNum = parseFloat(txInfo.amount);
const price = isErc20 ? tokenPrice : ethPrice; const price = isErc20 ? tokenPrice : ethPrice;
const amountUsd = price ? amountNum * price : null; const amountUsd = price ? amountNum * price : null;
@@ -156,7 +161,12 @@ function show(txInfo) {
warningsEl.innerHTML = localWarnings warningsEl.innerHTML = localWarnings
.map( .map(
(w) => (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(""); .join("");
warningsEl.style.visibility = "visible"; warningsEl.style.visibility = "visible";
@@ -206,7 +216,7 @@ function show(txInfo) {
// touches already occupies its space, so re-running it never moves anything. // touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) { function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH"; const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
const { canSend, codes } = validateTransfer({ const { canSend, codes } = validateTransfer({
isErc20, isErc20,

View File

@@ -11,6 +11,7 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
escapeHtml,
goBack, goBack,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -92,7 +93,7 @@ function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return "&nbsp;"; if (!addressHoldsFunds(addr)) return "&nbsp;";
const line = formatAddressTotal(getAddressValue(addr)); const line = formatAddressTotal(getAddressValue(addr));
const total = line 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 ( return (
`<p class="mb-1">This address holds a balance. Removing it does not ` + `<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. // 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 { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change, // When views are added, removed, or transitions between them change,
@@ -177,17 +191,26 @@ function showFlash(msg, duration = 2000) {
}, duration); }, 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) { function balanceLine(symbol, amount, price, tokenId) {
const qty = amount.toFixed(4); const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) || "&nbsp;" : "&nbsp;"; 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 const clickClass = tokenId
? " cursor-pointer hover:bg-hover balance-row" ? " cursor-pointer hover:bg-hover balance-row"
: ""; : "";
return ( return (
`<div class="flex text-xs${clickClass}"${tokenAttr}>` + `<div class="flex text-xs${clickClass}"${tokenAttr}>` +
`<span class="flex justify-between" style="width:42ch;max-width:100%">` + `<span class="flex justify-between" style="width:42ch;max-width:100%">` +
`<span>${symbol}</span>` + `<span>${escapeHtml(displaySymbol(symbol))}</span>` +
`<span>${qty}</span>` + `<span>${qty}</span>` +
`</span>` + `</span>` +
`<span class="text-right text-muted flex-1">${usd}</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>`; 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 // 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. // (e.g. "Address 1.2") or null if it's not one of ours.
function addressTitle(address, wallets) { function addressTitle(address, wallets) {
@@ -382,13 +399,26 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`; `</svg></span>`;
function etherscanAddressUrl(address) { // Block-explorer URLs. The origin is a per-network constant from
return `${currentNetwork().explorerUrl}/address/${address}`; // 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) { function etherscanLinkHtml(url) {
return ( return (
`<a href="${url}" target="_blank" rel="noopener" ` + `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center">${EXT_ICON}</a>` `class="inline-flex items-center">${EXT_ICON}</a>`
); );
} }
@@ -492,6 +522,7 @@ module.exports = {
addressColor, addressColor,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
displaySymbol,
addressTitle, addressTitle,
formatAddressHtml, formatAddressHtml,
renderAddressHtml, renderAddressHtml,
@@ -499,6 +530,7 @@ module.exports = {
attachCopyHandlers, attachCopyHandlers,
etherscanAddressUrl, etherscanAddressUrl,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, isoDate,

View File

@@ -8,6 +8,7 @@ const {
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
truncateMiddle, truncateMiddle,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -109,10 +110,13 @@ function renderHomeTxList(ctx) {
: tx.direction === "sent" || tx.direction === "contract" : tx.direction === "sent" || tx.direction === "contract"
? tx.to ? tx.to
: tx.from; : 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 const amountStr = tx.value
? escapeHtml(tx.value + " " + tx.symbol) ? escapeHtml(tx.value + " " + sym)
: escapeHtml(tx.symbol); : escapeHtml(sym);
const title = addressTitle(counterparty, state.wallets); const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = title || truncateMiddle(counterparty, maxAddr); const displayAddr = title || truncateMiddle(counterparty, maxAddr);
@@ -226,7 +230,7 @@ function walletListHtml() {
const defect = walletDefect(wallet); const defect = walletDefect(wallet);
html += `<div>`; html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`; 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 // No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce // xpub would only add one more address the key does not produce
// under the standard path. // under the standard path.
@@ -250,10 +254,13 @@ function walletListHtml() {
const titleBold = isActive ? "font-bold" : ""; const titleBold = isActive ? "font-bold" : "";
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`; html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
if (addr.ensName) { 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 += `<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 += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`; html += `</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr)); const addrTotal = formatAddressTotal(getAddressValue(addr));

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ const {
updateDebugBanner, updateDebugBanner,
showFlash, showFlash,
escapeHtml, escapeHtml,
displaySymbol,
flashCopyFeedback, flashCopyFeedback,
goBack, goBack,
pushCurrentView, pushCurrentView,
@@ -43,8 +44,11 @@ function renderSiteList(containerId, siteMap, stateKey) {
let html = ""; let html = "";
hostnames.forEach((hostname) => { hostnames.forEach((hostname) => {
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`; html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span>${hostname}</span>`; // A hostname the URL parser produced cannot carry a delimiter, so
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>`; // 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>`; html += `</div>`;
}); });
container.innerHTML = html; container.innerHTML = html;
@@ -73,9 +77,10 @@ function renderTrackedTokens() {
} }
let html = ""; let html = "";
state.trackedTokens.forEach((token, idx) => { state.trackedTokens.forEach((token, idx) => {
const sym = escapeHtml(displaySymbol(token.symbol));
const label = token.name const label = token.name
? escapeHtml(token.name) + " (" + escapeHtml(token.symbol) + ")" ? escapeHtml(token.name) + " (" + sym + ")"
: escapeHtml(token.symbol); : sym;
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`; html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
html += `<span>${label}</span>`; 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>`; 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 { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); 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"; : "border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs";
return ( return (
`<button class="settings-addtoken-quick ${cls}"` + `<button class="settings-addtoken-quick ${cls}"` +
` data-address="${t.address}"` + ` data-address="${escapeHtml(t.address)}"` +
` data-symbol="${t.symbol}"` + ` data-symbol="${escapeHtml(t.symbol)}"` +
` data-decimals="${t.decimals}"` + ` data-decimals="${escapeHtml(t.decimals)}"` +
` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` + ` data-name="${escapeHtml(t.name || "")}"` +
`${tracked ? " disabled" : ""}>${t.symbol}</button>` `${tracked ? " disabled" : ""}>${escapeHtml(t.symbol)}</button>`
); );
}) })
.join(""); .join("");
@@ -62,11 +62,11 @@ function renderDropdown() {
const tracked = isTracked(t.address); const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : ""); const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
html += html +=
`<option value="${t.address}"` + `<option value="${escapeHtml(t.address)}"` +
` data-symbol="${t.symbol}"` + ` data-symbol="${escapeHtml(t.symbol)}"` +
` data-decimals="${t.decimals}"` + ` data-decimals="${escapeHtml(t.decimals)}"` +
` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` + ` data-name="${escapeHtml(t.name || "")}"` +
`${tracked ? " disabled" : ""}>${label}</option>`; `${tracked ? " disabled" : ""}>${escapeHtml(label)}</option>`;
} }
sel.innerHTML = html; sel.innerHTML = html;
} }

View File

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

View File

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

View File

@@ -0,0 +1,103 @@
// The scale an ERC-20 amount in a dApp's calldata is displayed with, and what
// to display when there is no such scale.
//
// The approval screen decodes `transfer` and `approve` calldata into a
// quantity the user confirms against. That quantity is a base-unit integer,
// and turning it into a number a person can read needs the token's decimals.
// Assuming a scale is how a drain gets confirmed: a `transfer` of 5000000000
// units of a 6-decimal token is 5,000 tokens, but formatted with the ERC-20
// default of 18 it reads `0.0000`, and a user who reads zero signs.
//
// So a scale is either found or the amount is not formatted. Decimals are
// looked for in the bundled token list, then in the tokens the user tracks,
// then in what the block explorer reported for the contract; where none of
// them answers, unknownDecimalsAmount() renders the base-unit integer with the
// unknown scale stated, and no formatUnits() call is reached at all.
//
// This is the display counterpart to transferAmount.js, which takes the same
// stance on the wallet's own send path: an amount whose scale is unknown or
// disputed is refused rather than guessed at.
// Solidity's decimals() is a uint8, and every source here is ultimately
// reporting that call's result.
const { MAX_DECIMALS } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
// A decimals value as a number, or null if it is not one. The bundled list
// stores numbers, the explorer's copy arrives as a string, and a token the
// user added by hand can carry whatever lookupTokenInfo() got back, so the
// accepted types are enumerated rather than coerced: Number([]) is 0 and
// Number(true) is 1, so a coercing check would read an empty array as a scale
// of zero and format the amount as whole tokens.
function toDecimals(value) {
let n;
if (typeof value === "number") {
n = value;
} else if (typeof value === "bigint") {
if (value < 0n || value > BigInt(MAX_DECIMALS)) return null;
n = Number(value);
} else if (typeof value === "string") {
if (!/^[0-9]+$/.test(value)) return null;
n = Number(value);
} else {
return null;
}
if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) return null;
return n;
}
// Every decimals the explorer reported for this contract, across all the
// addresses whose balances have been fetched. They describe one contract, so
// they should agree; a set that does not agree is a scale in dispute, and this
// screen has no way to tell which member is the true one.
function explorerDecimals(lower, wallets) {
let found = null;
for (const wallet of wallets || []) {
for (const addr of wallet.addresses || []) {
for (const tb of addr.tokenBalances || []) {
if ((tb.address || "").toLowerCase() !== lower) continue;
const d = toDecimals(tb.decimals);
if (d === null) continue;
if (found !== null && found !== d) return null;
found = d;
}
}
}
return found;
}
// The decimals to render a token amount with, or null when nothing knows.
// `sources` is { trackedTokens, wallets }, both shaped as they are on `state`.
function resolveTokenDecimals(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
if (bundled) {
const d = toDecimals(bundled.decimals);
if (d !== null) return d;
}
const tracked = ((sources && sources.trackedTokens) || []).find(
(t) => (t.address || "").toLowerCase() === lower,
);
if (tracked) {
const d = toDecimals(tracked.decimals);
if (d !== null) return d;
}
return explorerDecimals(lower, sources && sources.wallets);
}
// What the amount line reads when the scale is unknown. The base units are
// exact and the caveat is part of the same string, so the number on the screen
// cannot be mistaken for a token quantity, and it can never read as zero for a
// transfer that is not zero.
function unknownDecimalsAmount(rawAmount) {
return String(rawAmount) + " base units (decimals unknown)";
}
module.exports = {
resolveTokenDecimals,
unknownDecimalsAmount,
};

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,208 @@
// The quantity the dApp approval screen shows for a decoded ERC-20 call.
//
// The screen's amount line is the only place a user sees how much a page is
// asking for, and it is decoded from calldata, which carries base units and
// no scale. Issue #306: decodeCalldata read decimals from the bundled token
// list alone and fell back to 18, so a `transfer` of 5000000000 units of a
// 6-decimal token — 5,000 tokens — was displayed as `0.0000` and confirmed.
//
// What is asserted here is that the scale is found wherever the wallet
// already has it, and that where it is nowhere at all no formatted number is
// produced: the amount line has to say base units and say the scale is
// unknown, because a wrong quantity that reads as zero is worse than an
// unwieldy correct one.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const iface = new Interface(ERC20_ABI);
// Outside the bundled list, as the great majority of ERC-20s are.
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 6 decimals.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111";
// 5,000 units of a 6-decimal token, the amount from the issue.
const FIVE_THOUSAND_AT_SIX = 5000000000n;
const MAX_UINT256 = (1n << 256n) - 1n;
function transferData(amount) {
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
function approveData(amount) {
return iface.encodeFunctionData("approve", [SPENDER, amount]);
}
// The Amount line as the approval screen renders it.
function amountLine(data, tokenAddress) {
const decoded = decodeCalldata(data, tokenAddress);
const detail = decoded.details.find((d) => d.label === "Amount");
return detail.value;
}
// A wallet holding `token` with the decimals the block explorer reported,
// shaped as balances.js writes it onto state.
function walletsHolding(token, decimals) {
return [
{
name: "Wallet 1",
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: [
{
address: token,
symbol: "NOVEL",
decimals,
balance: "5000.0",
},
],
},
],
},
];
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("resolveTokenDecimals", () => {
test("prefers the bundled list", () => {
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
expect(resolveTokenDecimals(USDC, state)).toBe(6);
});
test("reads a token the user tracks", () => {
state.trackedTokens = [
{
address: NOVEL_TOKEN.toLowerCase(),
symbol: "NOVEL",
decimals: 6,
},
];
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
});
test("reads the decimals the explorer reported", () => {
// Blockscout's copy arrives as a string.
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
});
test("falls past a tracked entry whose decimals are unusable", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: NaN },
];
state.wallets = walletsHolding(NOVEL_TOKEN, 6);
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
});
test("refuses a scale the explorer's own entries disagree about", () => {
const wallets = walletsHolding(NOVEL_TOKEN, 6);
wallets[0].addresses.push({
address: "0x" + "b".repeat(40),
balance: "0.0",
tokenBalances: [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 18 },
],
});
state.wallets = wallets;
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects values that are not a uint8", () => {
for (const decimals of [-1, 256, 1.5, true, [], {}, null, "6.0", ""]) {
state.trackedTokens = [{ address: NOVEL_TOKEN, decimals }];
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
}
});
test("is null when nothing knows the token", () => {
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBeNull();
});
});
describe("decodeCalldata amount", () => {
test("transfer of a tracked 6-decimal token shows the true quantity", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000");
});
test("transfer priced off the explorer's decimals shows the true quantity", () => {
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
expect(
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
).toBe("5000.0000");
});
test("transfer of an unknown-decimals token shows base units, not a number", () => {
const line = amountLine(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
expect(line).toBe("5000000000 base units (decimals unknown)");
expect(line).toBe(unknownDecimalsAmount(FIVE_THOUSAND_AT_SIX));
// The defect: any rendering that reads as a token quantity, and above
// all one that reads as zero.
expect(line).not.toMatch(/0\.0000/);
});
test("approve of a tracked 6-decimal token shows the true quantity", () => {
state.trackedTokens = [
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
];
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
"5000.0000",
);
});
test("approve of an unknown-decimals token shows base units, not a number", () => {
const line = amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN);
expect(line).toBe("5000000000 base units (decimals unknown)");
expect(line).not.toMatch(/0\.0000/);
});
test("an unbounded allowance is still named, with or without a scale", () => {
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
"Unlimited",
);
expect(amountLine(approveData(MAX_UINT256), USDC)).toBe("Unlimited");
});
test("a bundled token keeps its symbol and its scale", () => {
expect(amountLine(transferData(FIVE_THOUSAND_AT_SIX), USDC)).toBe(
"5000.0000 USDC",
);
});
test("the amount carried to the status screens is the same string", () => {
const decoded = decodeCalldata(
transferData(FIVE_THOUSAND_AT_SIX),
NOVEL_TOKEN,
);
const detail = decoded.details.find((d) => d.label === "Amount");
expect(detail.rawValue).toBe(
"5000000000 base units (decimals unknown)",
);
});
});

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

@@ -2,14 +2,14 @@
// state yet. // state yet.
// //
// The MV3 service worker is terminated when idle and revived by the next // The MV3 service worker is terminated when idle and revived by the next
// message, and nothing loads state at module scope. Both methods answer from // message, and nothing loads state at module scope. Both methods answered from
// currentNetwork(), which reads the module-level `state` singleton, so a // currentNetwork(), which reads the module-level `state` singleton, so a
// worker revived by the page's own message answered out of DEFAULT_STATE and // worker revived by the page's own message answered out of DEFAULT_STATE and
// told a page it was on mainnet while the user was on Sepolia // told a page it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317). // (https://git.eeqj.de/sneak/AutistMask/issues/317).
// //
// This file therefore uses the REAL state module and never calls loadState() // This file therefore uses the REAL state module and never calls loadState()
// itself: the handler has to do it. Same shape as // itself: the handler has to answer from storage on its own. Same shape as
// tests/coldWorkerChainSwitch.test.js, which covers the write side. // tests/coldWorkerChainSwitch.test.js, which covers the write side.
const { networkById } = require("../src/shared/networks"); const { networkById } = require("../src/shared/networks");
@@ -23,6 +23,8 @@ const UNKNOWN_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet"); const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia"); const SEPOLIA = networkById("sepolia");
const REFRESHED_BALANCE = "1.5";
function storedProfile(networkId) { function storedProfile(networkId) {
return { return {
hasWallet: true, hasWallet: true,
@@ -54,36 +56,50 @@ afterEach(() => {
}); });
// Load the background worker with the real state module behind it, over a // Load the background worker with the real state module behind it, over a
// storage stub that keeps what is written — so the test can also show that // storage stub that keeps what is written.
// answering a read method persists nothing. //
function loadColdWorker(networkId) { // The stub structured-clones in both directions, as the real
// chrome.storage.local does. A stub that handed back the live stored object
// would alias it into whatever read it, so an in-place mutation of a detached
// copy would appear to have reached storage and this whole class of defect
// would be invisible here.
//
// opts.refreshBalances replaces the balances stub, so a test can hold a
// refresh open across a message.
function loadColdWorker(networkId, opts) {
jest.resetModules(); jest.resetModules();
const options = opts || {};
jest.doMock("../src/shared/balances", () => ({ jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}), getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}), refreshBalances: options.refreshBalances || jest.fn(async () => {}),
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false, isPhishingDomain: () => false,
})); }));
let alarmHandlers = {};
jest.doMock("../src/shared/alarms", () => ({ jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance", BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1, BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}), ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(), registerAlarmHandlers: jest.fn((handlers) => {
alarmHandlers = handlers;
}),
})); }));
const store = { autistmask: storedProfile(networkId) }; const store = { autistmask: storedProfile(networkId) };
let messageListener = null; let messageListener = null;
const set = jest.fn(async (items) => { const set = jest.fn(async (items) => {
store.autistmask = items.autistmask; store.autistmask = structuredClone(items.autistmask);
}); });
global.chrome = { global.chrome = {
storage: { storage: {
local: { local: {
get: jest.fn(async () => ({ autistmask: store.autistmask })), get: jest.fn(async () => structuredClone(store)),
set, set,
}, },
}, },
@@ -129,7 +145,12 @@ function loadColdWorker(networkId) {
return result; return result;
} }
return { rpc, persisted: () => store.autistmask, storageSet: set }; return {
rpc,
persisted: () => store.autistmask,
storageSet: set,
fireBalanceAlarm: () => alarmHandlers.balance(),
};
} }
describe("chain identity read by a worker that never loaded state", () => { describe("chain identity read by a worker that never loaded state", () => {
@@ -189,4 +210,49 @@ describe("chain identity read by a worker that never loaded state", () => {
expect(bg.storageSet).not.toHaveBeenCalled(); expect(bg.storageSet).not.toHaveBeenCalled();
expect(bg.persisted()).toEqual(storedProfile("sepolia")); expect(bg.persisted()).toEqual(storedProfile("sepolia"));
}); });
test("a chain read arriving mid-refresh does not discard the refresh", async () => {
// Any page reaches these two methods, and the injected provider sends
// eth_chainId on every page load, so this overlap is ordinary traffic
// rather than a contrived race.
//
// backgroundRefresh() hands the singleton's wallets to
// refreshBalances(), which mutates those address objects in place once
// the network round trip resolves, and only then saves. Answering the
// page by calling loadState() would replace state.wallets mid-flight,
// so the refreshed balances would land on detached objects and the
// save that follows would persist the pre-refresh values — while still
// stamping lastBalanceRefresh, suppressing the redo.
let releaseRoundTrip;
const roundTrip = new Promise((resolve) => {
releaseRoundTrip = resolve;
});
let refreshReachedNetwork;
const inFlight = new Promise((resolve) => {
refreshReachedNetwork = resolve;
});
const bg = loadColdWorker("sepolia", {
refreshBalances: async (wallets) => {
refreshReachedNetwork();
await roundTrip;
// In place, on the objects handed in — as balances.js does.
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await inFlight;
expect(await bg.rpc("eth_chainId", UNKNOWN_ORIGIN)).toEqual({
result: SEPOLIA.chainId,
});
releaseRoundTrip();
await refresh;
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
});
}); });

View File

@@ -268,11 +268,15 @@ function ethCallResult(req, opts) {
return ZERO_WORD; 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 { return {
address_hash: STUB_TOKEN.address, address_hash: STUB_TOKEN.address,
address: STUB_TOKEN.address, address: STUB_TOKEN.address,
symbol: STUB_TOKEN.symbol, symbol: (opts && opts.tokenSymbolOverride) || STUB_TOKEN.symbol,
name: STUB_TOKEN.name, name: STUB_TOKEN.name,
decimals: STUB_TOKEN.decimals, decimals: STUB_TOKEN.decimals,
holders_count: STUB_TOKEN.holders, 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. // One received ERC-20 transfer of 1.5 E2E to the address under test.
function tokenTransferItems(address) { function tokenTransferItems(address, opts) {
return [ return [
{ {
transaction_hash: STUB_TX_HASH, transaction_hash: STUB_TX_HASH,
@@ -290,7 +294,7 @@ function tokenTransferItems(address) {
from: { hash: STUB_COUNTERPARTY }, from: { hash: STUB_COUNTERPARTY },
to: { hash: address }, to: { hash: address },
total: { decimals: STUB_TOKEN.decimals, value: "1500000" }, 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 // 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 // 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. // the only way the confirmation screen's ERC-20 path can be reached.
function tokenBalanceItems() { function tokenBalanceItems(opts) {
return [ return [
{ {
value: "1500000", value: "1500000",
token: tokenObject(), token: tokenObject(opts),
}, },
]; ];
} }
@@ -596,6 +600,9 @@ function traceEnabled(raw) {
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for * @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* the stub token, in place of the value Blockscout reports for it. This is * 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. * 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 * @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves. * confirmed receipt instead of null, so a wait screen resolves.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
@@ -674,14 +681,14 @@ async function installNetworkStubs(ctx, opts) {
return jsonResponse(route, { return jsonResponse(route, {
items: items:
opts.seedTokenTransfer && addr opts.seedTokenTransfer && addr
? tokenTransferItems(addr) ? tokenTransferItems(addr, opts)
: [], : [],
}); });
} }
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
return jsonResponse( return jsonResponse(
route, route,
opts.seedTokenBalance ? tokenBalanceItems() : [], opts.seedTokenBalance ? tokenBalanceItems(opts) : [],
); );
} }
for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) { 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"); 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) // ------------------------------------------- dApp round trips (#183)
// //
// The seam. Everything above drives the popup on its own; this section is // 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 // something other than the value the same fixture reports through
// Blockscout. The token that lies about its scale (#305). // Blockscout. The token that lies about its scale (#305).
tokenDecimalsOverride: null, 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 // Whether eth_getTransactionReceipt confirms a transaction rather than
// answering "not mined yet". // answering "not mined yet".
seedReceipt: false, 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 // an exact match on the token set is what keeps the next edit from
// smuggling one in alongside. // 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 // build.js copies these files to dist/<target>/manifest.json verbatim, so
// what is asserted here is what ships. // what is asserted here is what ships.
@@ -21,8 +48,22 @@ const path = require("path");
const MANIFEST_DIR = path.join(__dirname, "..", "manifest"); const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
const EXPECTED_SCRIPT_SRC = ["'self'", "'wasm-unsafe-eval'"]; const EXPECTED_DIRECTIVES = {
const EXPECTED_OBJECT_SRC = ["'self'"]; "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 = [ const FORBIDDEN_SOURCES = [
"'unsafe-eval'", "'unsafe-eval'",
@@ -53,26 +94,31 @@ function parseCsp(policy) {
function assertPolicy(policy) { function assertPolicy(policy) {
const directives = parseCsp(policy); const directives = parseCsp(policy);
expect(Object.keys(directives).sort()).toEqual([ // Exact, in both directions: a directive that appears here and not in
"object-src", // EXPECTED_DIRECTIVES is an unreviewed addition, and one that
"script-src", // disappears silently reopens whatever it was closing.
]); expect(Object.keys(directives).sort()).toEqual(
expect(directives["script-src"].slice().sort()).toEqual( Object.keys(EXPECTED_DIRECTIVES).sort(),
EXPECTED_SCRIPT_SRC,
); );
expect(directives["object-src"].slice().sort()).toEqual( for (const [name, sources] of Object.entries(EXPECTED_DIRECTIVES)) {
EXPECTED_OBJECT_SRC, expect([name, directives[name].slice().sort()]).toEqual([
); name,
for (const source of FORBIDDEN_SOURCES) { sources.slice().sort(),
expect(directives["script-src"]).not.toContain(source); ]);
expect(directives["object-src"]).not.toContain(source); }
for (const name of SCRIPT_DIRECTIVES) {
for (const source of FORBIDDEN_SOURCES) {
expect(name + " " + directives[name].join(" ")).not.toContain(
" " + source,
);
}
} }
} }
describe("shipped Content Security Policy", () => { describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the // MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs. // 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; const csp = readManifest("chrome").content_security_policy;
expect(typeof csp).toBe("object"); expect(typeof csp).toBe("object");
expect(Object.keys(csp)).toEqual(["extension_pages"]); 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 // Firefox before 106 rejects an MV2 policy string that omits
// object-src and falls back to its own default, discarding everything // object-src and falls back to its own default, discarding everything
// declared here. Same policy as Chrome, different manifest shape. // 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; const csp = readManifest("firefox").content_security_policy;
expect(typeof csp).toBe("string"); expect(typeof csp).toBe("string");
assertPolicy(csp); assertPolicy(csp);