A token whose symbol is " ETH " misses KNOWN_SYMBOLS on all three surfaces, so isSpoofedSymbol() reports it is not a spoof — while HTML collapses the whitespace and displays it as ETH, right next to the user's real ETH.
That is the exact confusion #235 just closed, reachable by adding one space. Found by the independent review of #257 and confirmed pre-existing — unchanged by that PR, which is why it was not blocked on it.
The attacker controls the symbol string entirely: it is whatever the ERC-20 contract returns.
Why it is now cheap
#257 moved the rule into a single module, src/shared/symbolSpoof.js, and all three surfaces call it. Normalizing inside isSpoofedSymbol() fixes every surface at once — that was the point of consolidating it.
Implementation requirements
Normalize the symbol inside src/shared/symbolSpoof.js so all three call sites inherit it. Trim at minimum.
Decide deliberately how far normalization should go and say why in the PR body. Trimming ASCII whitespace catches " ETH "; it does not catch a zero-width space, a non-breaking space, or Unicode confusables such as Cyrillic Е. Each of those renders indistinguishably from ETH to a user. Say which classes are covered and which are knowingly left open.
Normalization must not create false positives — a legitimate token whose symbol genuinely contains a space must not start being filtered. Check whether any entry in KNOWN_SYMBOLS or the bundled token list has one.
Two adjacent findings from the same review, worth handling here or ruling out:
A mis-cased item.token.type (anything but exactly "ERC-20") drops the row entirely at src/shared/balances.js:69, before any filtering. Fail-closed for spam, but it also silently hides a legitimate holding if an explorer ever varies the casing.
With hideSpoofedSymbols off, history still shows what the balance list now hides. Deliberate, from #176 — confirm it is still the intent now that the three surfaces otherwise agree.
Definition of done
A fake token with symbol " ETH " is filtered from the balance list, history and the send selector.
Tests cover the padded symbol on all three surfaces, demonstrated failing first.
A legitimate token is not newly filtered by the normalization.
The PR body states which confusable classes are covered and which are knowingly left open.
TODO.md updated in the same commit.
make check passes.
A token whose symbol is `" ETH "` misses `KNOWN_SYMBOLS` on all three surfaces, so `isSpoofedSymbol()` reports it is not a spoof — while HTML collapses the whitespace and displays it as `ETH`, right next to the user's real ETH.
That is the exact confusion https://git.eeqj.de/sneak/AutistMask/issues/235 just closed, reachable by adding one space. Found by the independent review of https://git.eeqj.de/sneak/AutistMask/pulls/257 and confirmed pre-existing — unchanged by that PR, which is why it was not blocked on it.
The attacker controls the symbol string entirely: it is whatever the ERC-20 contract returns.
## Why it is now cheap
https://git.eeqj.de/sneak/AutistMask/pulls/257 moved the rule into a single module, `src/shared/symbolSpoof.js`, and all three surfaces call it. Normalizing inside `isSpoofedSymbol()` fixes every surface at once — that was the point of consolidating it.
## Implementation requirements
- Normalize the symbol inside `src/shared/symbolSpoof.js` so all three call sites inherit it. Trim at minimum.
- Decide deliberately how far normalization should go and say why in the PR body. Trimming ASCII whitespace catches `" ETH "`; it does not catch a zero-width space, a non-breaking space, or Unicode confusables such as Cyrillic `Е`. Each of those renders indistinguishably from `ETH` to a user. Say which classes are covered and which are knowingly left open.
- Normalization must not create false positives — a legitimate token whose symbol genuinely contains a space must not start being filtered. Check whether any entry in `KNOWN_SYMBOLS` or the bundled token list has one.
Two adjacent findings from the same review, worth handling here or ruling out:
- A mis-cased `item.token.type` (anything but exactly `"ERC-20"`) drops the row entirely at `src/shared/balances.js:69`, before any filtering. Fail-closed for spam, but it also silently hides a legitimate holding if an explorer ever varies the casing.
- With `hideSpoofedSymbols` off, history still shows what the balance list now hides. Deliberate, from https://git.eeqj.de/sneak/AutistMask/issues/176 — confirm it is still the intent now that the three surfaces otherwise agree.
## Definition of done
- [ ] A fake token with symbol `" ETH "` is filtered from the balance list, history and the send selector.
- [ ] Tests cover the padded symbol on all three surfaces, demonstrated failing first.
- [ ] A legitimate token is not newly filtered by the normalization.
- [ ] The PR body states which confusable classes are covered and which are knowingly left open.
- [ ] `TODO.md` updated in the same commit.
- [ ] `make check` passes.
clawbot
added this to the 1.0.0 milestone 2026-08-12 10:56:57 +02:00
isSpoofedSymbol() now normalizes before the KNOWN_SYMBOLS lookup — NFKC, every Unicode format character (\p{Cf}) removed wherever it sits, trim, uppercase — so all three surfaces inherit it with no call-site change.
Boundary: covers ASCII and non-ASCII whitespace padding, zero-width and other invisible format characters, and compatibility variants (fullwidth). Knowingly open, each asserted as open by a test: confusables that are distinct letters (Cyrillic capital Ie, Greek capital Epsilon), bidi reordering, and interior whitespace (E T H renders differently, so folding it would filter a token nobody could confuse).
False positives: no entry in KNOWN_SYMBOLS (506) and no bundled token symbol (512) contains whitespace or a non-ASCII character, so nothing legitimate is newly filtered; a test walks the whole table and asserts it.
Adjacent findings: the token-type gate in src/shared/balances.js is now case-insensitive (erc-721 and ERC-20-EXTRA still dropped, both directions tested). The hideSpoofedSymbols asymmetry is unchanged and confirmed deliberate — the switch is a history-only user escape hatch, per #176.
Verified: the 10 new tests were written first and watched fail on unmodified next (all three surfaces), then pass. make check green — 595 tests, 25 suites, test-verify-build 18 cases, prettier clean — and green in-container via script/cibuild with RUN make check executing rather than cached. Mutation checks: deleting the no-contract native guard kills 21 tests, inverting the address comparison kills 26, dropping .trim() kills 6, dropping the \p{Cf} strip kills 2.
Implemented in [#270](https://git.eeqj.de/sneak/AutistMask/pulls/270).
`isSpoofedSymbol()` now normalizes before the `KNOWN_SYMBOLS` lookup — NFKC, every Unicode format character (`\p{Cf}`) removed wherever it sits, trim, uppercase — so all three surfaces inherit it with no call-site change.
Boundary: covers ASCII and non-ASCII whitespace padding, zero-width and other invisible format characters, and compatibility variants (fullwidth). Knowingly open, each asserted as open by a test: confusables that are distinct letters (Cyrillic capital Ie, Greek capital Epsilon), bidi reordering, and interior whitespace (`E T H` renders differently, so folding it would filter a token nobody could confuse).
False positives: no entry in `KNOWN_SYMBOLS` (506) and no bundled token symbol (512) contains whitespace or a non-ASCII character, so nothing legitimate is newly filtered; a test walks the whole table and asserts it.
Adjacent findings: the token-type gate in `src/shared/balances.js` is now case-insensitive (`erc-721` and `ERC-20-EXTRA` still dropped, both directions tested). The `hideSpoofedSymbols` asymmetry is unchanged and confirmed deliberate — the switch is a history-only user escape hatch, per [#176](https://git.eeqj.de/sneak/AutistMask/issues/176).
Verified: the 10 new tests were written first and watched fail on unmodified `next` (all three surfaces), then pass. `make check` green — 595 tests, 25 suites, `test-verify-build` 18 cases, prettier clean — and green in-container via `script/cibuild` with `RUN make check` executing rather than cached. Mutation checks: deleting the no-contract native guard kills 21 tests, inverting the address comparison kills 26, dropping `.trim()` kills 6, dropping the `\p{Cf}` strip kills 2.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
A token whose symbol is
" ETH "missesKNOWN_SYMBOLSon all three surfaces, soisSpoofedSymbol()reports it is not a spoof — while HTML collapses the whitespace and displays it asETH, right next to the user's real ETH.That is the exact confusion #235 just closed, reachable by adding one space. Found by the independent review of #257 and confirmed pre-existing — unchanged by that PR, which is why it was not blocked on it.
The attacker controls the symbol string entirely: it is whatever the ERC-20 contract returns.
Why it is now cheap
#257 moved the rule into a single module,
src/shared/symbolSpoof.js, and all three surfaces call it. Normalizing insideisSpoofedSymbol()fixes every surface at once — that was the point of consolidating it.Implementation requirements
src/shared/symbolSpoof.jsso all three call sites inherit it. Trim at minimum." ETH "; it does not catch a zero-width space, a non-breaking space, or Unicode confusables such as CyrillicЕ. Each of those renders indistinguishably fromETHto a user. Say which classes are covered and which are knowingly left open.KNOWN_SYMBOLSor the bundled token list has one.Two adjacent findings from the same review, worth handling here or ruling out:
item.token.type(anything but exactly"ERC-20") drops the row entirely atsrc/shared/balances.js:69, before any filtering. Fail-closed for spam, but it also silently hides a legitimate holding if an explorer ever varies the casing.hideSpoofedSymbolsoff, history still shows what the balance list now hides. Deliberate, from #176 — confirm it is still the intent now that the three surfaces otherwise agree.Definition of done
" ETH "is filtered from the balance list, history and the send selector.TODO.mdupdated in the same commit.make checkpasses.Implemented in #270.
isSpoofedSymbol()now normalizes before theKNOWN_SYMBOLSlookup — NFKC, every Unicode format character (\p{Cf}) removed wherever it sits, trim, uppercase — so all three surfaces inherit it with no call-site change.Boundary: covers ASCII and non-ASCII whitespace padding, zero-width and other invisible format characters, and compatibility variants (fullwidth). Knowingly open, each asserted as open by a test: confusables that are distinct letters (Cyrillic capital Ie, Greek capital Epsilon), bidi reordering, and interior whitespace (
E T Hrenders differently, so folding it would filter a token nobody could confuse).False positives: no entry in
KNOWN_SYMBOLS(506) and no bundled token symbol (512) contains whitespace or a non-ASCII character, so nothing legitimate is newly filtered; a test walks the whole table and asserts it.Adjacent findings: the token-type gate in
src/shared/balances.jsis now case-insensitive (erc-721andERC-20-EXTRAstill dropped, both directions tested). ThehideSpoofedSymbolsasymmetry is unchanged and confirmed deliberate — the switch is a history-only user escape hatch, per #176.Verified: the 10 new tests were written first and watched fail on unmodified
next(all three surfaces), then pass.make checkgreen — 595 tests, 25 suites,test-verify-build18 cases, prettier clean — and green in-container viascript/cibuildwithRUN make checkexecuting rather than cached. Mutation checks: deleting the no-contract native guard kills 21 tests, inverting the address comparison kills 26, dropping.trim()kills 6, dropping the\p{Cf}strip kills 2.