fix: render a hostile token symbol as text, and put a floor under the CSP (closes #307) #327
Reference in New Issue
Block a user
Delete Branch "issue-307-escape-html"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #307.
The defect
A token's symbol is whatever its
symbol()returns, the block explorer passes it through unfiltered, andbalanceLine()interpolated it into aninnerHTMLstring. 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 is used to typing their password.What changed
escapeHtmlmoved to a new DOM-freesrc/shared/html.jsas a pure string replace over&,<,>,"and', re-exported fromhelpers.jsso no caller moved. The implementation it replaces round-tripped through a detached element'stextContent, which escapes neither quote character — and it was already used insidedata-copy="...", so quote escaping was load-bearing before this change, not after.Every interpolation into an
innerHTMLstring acrosssrc/popup/views/was audited, not just the reported one. Also unescaped, and now fixed:renderAddressHtmlescaped its copy of the ENS name; this list did not);href, plus the blockiedata:URI in two views;src/shared/etherscanLabels.js, which builds one out of scraped explorer markup;addToken.jsandsettingsAddToken.js, which were using an ad-hoc.replace(/"/g, "&quot;").Explorer URLs are now built by one
explorerUrl(kind, value)helper that percent-encodes the path segment, so afrom/toout of explorer JSON cannot re-point the link elsewhere in the explorer. What stays bare is markup fragments this code just built, loop indices and locally computed numbers; the rule and its reason are stated at the top ofhelpers.jsso the grep stays readable.CSP. Both manifests now declare
default-src 'self'withframe-src 'none'.default-src 'self'on its own breaks the popup, so the policy is written out:'self'style-src 'self' 'unsafe-inline'index.htmland the view helpers set presentation throughstyle="..."attributes. Chrome enforcesstyle-srcon attributes, and Firefox has never implementedstyle-src-attr, so there is no narrower spelling that works on both. Script stays underscript-src, which does not allow'unsafe-inline'.img-src 'self' data:data:PNGs assigned toimg.src.connect-src 'self' https: http:http://127.0.0.1is supported — the Firefox e2e suite runs on exactly that.frame-src 'none',form-action 'none',base-uri 'none'default-srcat all.tests/manifest.test.jsnow pins the whole directive set exactly, in both directions; README.md carries the reasoning.Length cap. Displayed symbols are capped at 12 characters in
src/shared/symbolDisplay.js— the boundlookupTokenInfo()already applied to a symbol read straight off a contract, while the explorer path had none. The longest symbol in the 512-entry bundled list isMSYRUPUSDPat 10, so no real token is truncated. The module states that it is a layout bound and not the security control.isSpoofedSymbolis untouched.Verification
make checkgreen: 39 suites, 811 tests; lint ran in the container (#11 [lint 1/1] RUN make lint, notCACHED), prettier clean.make test-e2e(Chrome) 55/55, including the new browser test:make test-e2e-firefox8/8 steps, which is what proves the CSP change does not break the popup in the other engine — the loopbackhttp://dApp and thehttp://127.0.0.1stub node both still reachable underconnect-src.ok 2 - the popup compiles WebAssembly under the shipped CSP (#182)still passes in Chrome, so libsodium is still on the WASM backend.Verified failing first, four ways:
escapeHtmlto& < >— the escape the oldtextContentround trip actually performed — fails 5 unit tests, among themkeeps a quote-bearing token id inside its attribute.default-srcfrommanifest/chrome.jsonfails 2 manifest tests.<iframe id="pwn" src="https://dapp.e2e.test/"></iframe> from <div class="mt-3">…</div> subtree intercepts pointer eventson the Back button. Note thatframe-src 'none'stops such a frame loading but not existing, which is why the test asserts the iframe count and the literal rendered text separately, and why the count is taken on the address screen before anything is clicked.Not in this PR
https://git.eeqj.de/sneak/AutistMask/issues/322andhttps://git.eeqj.de/sneak/AutistMask/issues/323are on the same screens and are deliberately untouched.PASS. Independently re-derived the
${audit oversrc/popup/views/, re-ran everything, and reproduced two of the four mutations; nothing blocking.Evidence the checks ran:
make checkgreen here (39 suites / 811 tests; lint stage#11 [lint 1/1] RUN make lint ... DONE 5.1s, notCACHED; prettier clean).make test-e2e55/55 includingok 44andok 2 - the popup compiles WebAssembly under the shipped CSP (#182).make test-e2e-firefox8/8, with no manifest-CSP parse error in the Firefox console output. No CSP violation appeared in either browser log, and that is asserted rather than merely unobserved:tests/e2e/harness.js:114records every pageconsole.errorandrun.js:3548-3551fails the run on one, so a Chrome "Refused to apply inline style" would have failed the suite. Mutations reproduced independently: cuttingescapeHtmlto& < >fails 5 tests includingkeeps a quote-bearing token id inside its attribute; reverting onlybalanceLine's interpolation to${symbol}givesnot ok 44 ... the address screen contains 2 iframe(s), taken before any click, so the browser test is not vacuous. Both reverted, tree clean, no containers left, e2e image tags as found.explorerUrl()probed with",>,javascript:,../, newline,?/#,'and an astral character: all neutralised, and the'thatencodeURIComponentleaves alone is caught by the escape intohref="...".Non-blocking findings, in descending order:
src/shared/networks.js:47—explorerLink(network, type, value)is an exported, un-encoded twin of the newexplorerUrl(). It is dead code and was dead before this PR, so it is not a regression, but this PR's stated remediation is "one helper that percent-encodes the path segment" and this leaves a second exported builder that does not. Acceptable: delete it, or make it delegate to the encoding one.src/popup/views/addressToken.js:202—${tokenDecimals}is the one value-shaped interpolation left bare, against the rule this PR itself writes at the top ofhelpers.js. Traced every writer:balances.js:75parseInt,lookupTokenInfoNumber(),settingsAddToken.js:43,104parseInt, bundled list numeric, and no dApp-writable path intotrackedTokens. Safe today; it is only the greppable rule that does not hold.src/popup/views/home.js:267interpolatesaddrTotalbare whiledeleteAddress.js:96escapes the identicalformatAddressTotal()output. Both safe, inconsistent with each other.displaySymbol()slices at a UTF-16 boundary, so an astral-plane symbol truncates onto a lone surrogate — verified: 20 emoji in gives"\ud83d…"as the 12th/11th units, which renders U+FFFD. Cosmetic, escaping unaffected.Opinion on the three loosenings, all acceptable to ship:
style-src 'unsafe-inline'— the Firefox claim is accurate (style-src-attrwas never implemented there, nor'unsafe-hashes'), so no narrower spelling works on both engines while the 39 staticstyle="..."attributes insrc/popup/index.htmland the view helpers remain. Note for the record that the 57.style.foo =assignments are CSSOM and CSP-exempt, so they are not what forces this — only the 39 attributes are, and moving those to classes would remove the loosening entirely. Residual risk is small: with the escaping in place nothing can inject astyleattribute, and the CSS exfiltration channels are shut anyway byimg-src 'self' data:andfont-srcfalling back todefault-src 'self'. Worth a follow-up issue; I did not file one.img-src data:— no injection route. The blockie URI ismakeBlockie()over a hex address and is now escaped intosrc="...";data:appears in no script-fetching directive, and SVG in<img>does not script.connect-src https: http:— the weakest of the three and it cannot be narrowed: a static manifest cannot know a user-configured RPC endpoint. It gives back nothing on the #307 attack, since injected markup cannot run script underscript-src 'self'andform-action 'none'plusimg-srcclose the non-script channels. Two things worth knowing:ws:/wss:are not matched byhttp:/https:and so fall back todefault-src 'self', i.e. blocked — harmless, because the wallet only buildsJsonRpcProviderover fetch (balances.js:28) andsettings.jsvalidates an endpoint by fetching it. The real residual cost is supply-chain, a malicious bundled dependency exfiltrating to any origin, which is pre-existing and not a regression here.Confirmed and not repeated: all six DoD items; the
walletDefectHtmlreasoning (only the frozenDEFECTSconstants reach it, and the module already says so);isSpoofedSymboluntouched and nothing relying on the 12-char cap for safety; both manifests byte-identical and pinned exactly in both directions; CI green onada41bf; merges clean intonextandmain; single commit ending(closes #307)withTODO.mdin it, author and committer bothclawbot; no Claude/Anthropic reference or attribution trailer anywhere in the diff or the commit message; no scope creep;make fmtclean. The disclosed one-off rawnode -eleft no residue in the committed tree.