fix: fold invisible characters before the known-symbol spoof check (closes #260)
All checks were successful
check / check (push) Successful in 36s
All checks were successful
check / check (push) Successful in 36s
A token calling itself " ETH " missed the known-symbol table entirely, so the spoof check reported it was not a spoof -- while HTML collapsed the whitespace and displayed it as ETH next to the user's real ETH. One space defeated the filter. The symbol is now folded before the lookup: NFKC, remove what paints nothing, trim, uppercase. The rule is "remove what paints nothing"; the Unicode classes are how that is spelled, which is why U+007F is named separately -- it is a control, reached by no class, and measures identical to no character at all. Every width in the module comment was measured in the pinned browser rather than reasoned about, and the boundary is pinned from both sides: widening to all control characters fails the visible-controls test, narrowing back fails the invisible-characters test. Two default-ignorable code points do paint a box and are folded anyway, which can only hide a token that does not resemble the symbol it folds to -- the harmless direction, recorded rather than glossed. Confusables that are distinct letters, bidi reordering and interior whitespace are knowingly left open and asserted open by tests.
This commit was merged in pull request #270.
This commit is contained in:
@@ -13,6 +13,11 @@
|
||||
// which has no contract at all, so no contract may bear it and every one
|
||||
// that does is a spoof. "ETH" is the only such entry today; the rule is
|
||||
// written so that a second one needs no change here or at any call site.
|
||||
//
|
||||
// The symbol is attacker-controlled — it is whatever the ERC-20 contract
|
||||
// returns — so the lookup is done on a normalized form (issue #260): the
|
||||
// question is whether the symbol reaches the user's eye as a known one,
|
||||
// since that is what the user acts on.
|
||||
|
||||
const { KNOWN_SYMBOLS } = require("./tokenList");
|
||||
|
||||
@@ -22,6 +27,59 @@ function normalizeAddress(addr) {
|
||||
return (addr || "").toLowerCase();
|
||||
}
|
||||
|
||||
// Fold a symbol onto what a user actually sees, and no further:
|
||||
//
|
||||
// NFKC collapses compatibility variants that render as the ASCII
|
||||
// letters they imitate — fullwidth ETH, styled mathematical
|
||||
// letters — and maps the non-ASCII spaces onto U+0020.
|
||||
// strip drops what paints nothing: \p{Cf} plus
|
||||
// \p{Default_Ignorable_Code_Point} plus U+007F. That covers
|
||||
// the format characters (zero-width space, joiner and
|
||||
// non-joiner, word joiner, soft hyphen, byte-order mark, bidi
|
||||
// marks and overrides), the variation selectors, the Hangul
|
||||
// fillers, and DELETE. Removed everywhere, not merely at the
|
||||
// ends.
|
||||
// trim removes surrounding whitespace, which HTML collapses:
|
||||
// `" ETH "` is painted next to the user's real ETH as `ETH`.
|
||||
// toUpperCase makes the comparison case-insensitive, as before.
|
||||
//
|
||||
// The rule is "strip what paints nothing". The Unicode classes are how
|
||||
// that is spelled, not what it means, which is why U+007F is named on its
|
||||
// own: it is a control rather than a default-ignorable character, so no
|
||||
// class here reaches it, yet it paints nothing all the same. Measured in
|
||||
// the repo's pinned e2e Chromium (16px sans-serif, plain `ETH` = 32.00px,
|
||||
// so an invisible prefix leaves 32.00px):
|
||||
//
|
||||
// U+007F, U+3164, U+115F, U+FE0F, U+FE00 32.00px — invisible
|
||||
// U+FFA0 40.00px — a box
|
||||
// U+1160 48.00px — a box
|
||||
// U+0001, U+0085, U+0090 48.00px — a box
|
||||
//
|
||||
// U+1160 and U+FFA0 are `Default_Ignorable_Code_Point` members that font
|
||||
// fallback nonetheless draws, and they are stripped anyway: erring toward
|
||||
// hiding a token that does not look like `ETH` is the harmless direction of
|
||||
// the two. The other controls are left alone for the same reason read the
|
||||
// other way — a symbol carrying a visible box does not reach the eye as
|
||||
// `ETH`, so filtering it would hide a token the user could not have
|
||||
// confused with the native asset.
|
||||
//
|
||||
// Deliberately not folded, and asserted as open in tests/symbolSpoof.test.js:
|
||||
// interior whitespace (`E T H` renders as `E T H`, so folding it would filter
|
||||
// a token nobody could confuse with the native asset), confusables that are
|
||||
// distinct letters rather than compatibility variants (Cyrillic capital Ie,
|
||||
// U+0415; Greek capital Epsilon, U+0395), bidi reordering, which needs the
|
||||
// bidi algorithm rather than a character filter, and the visible controls.
|
||||
//
|
||||
// This decides only how the question is asked. Nothing here changes what a
|
||||
// surface displays; a token still shows the symbol it reports.
|
||||
function normalizeSymbol(symbol) {
|
||||
return String(symbol || "")
|
||||
.normalize("NFKC")
|
||||
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// True when a token bearing `symbol` from contract `contractAddress` is
|
||||
// impersonating a known symbol.
|
||||
//
|
||||
@@ -31,7 +89,7 @@ function normalizeAddress(addr) {
|
||||
function isSpoofedSymbol(symbol, contractAddress) {
|
||||
const contract = normalizeAddress(contractAddress);
|
||||
if (!contract) return false;
|
||||
const sym = (symbol || "").toUpperCase();
|
||||
const sym = normalizeSymbol(symbol);
|
||||
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(sym);
|
||||
if (legit === null) return true;
|
||||
|
||||
Reference in New Issue
Block a user