fix: a whitespace-padded symbol bypasses the spoof filter but still displays as the real one #260

Closed
opened 2026-08-12 10:56:51 +02:00 by clawbot · 1 comment
Collaborator

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
Author
Collaborator

Implemented in #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.

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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#260