Compare commits

...

4 Commits

Author SHA1 Message Date
ada643cb44 fix: judge the symbol a user sees, not the bytes a contract returns (closes #260)
All checks were successful
check / check (push) Successful in 32s
A token whose symbol is " ETH " missed KNOWN_SYMBOLS on all three surfaces
while HTML collapsed the padding and painted it as ETH next to the user's
real ETH. isSpoofedSymbol() now normalizes before the lookup: NFKC, every
Unicode format character removed wherever it sits, then trimmed, then
uppercased. All three surfaces inherit it unchanged.

Covered: ASCII and Unicode whitespace padding, zero-width and other
invisible format characters, and compatibility variants such as fullwidth
letters. Knowingly left open, and asserted as open in the suite: confusables
that are distinct letters (Cyrillic capital Ie), bidi reordering, and
interior whitespace, which renders differently and so is not the confusion.

No symbol in KNOWN_SYMBOLS or the bundled token list contains whitespace or
a non-ASCII character, so nothing legitimate is newly filtered; a test walks
the whole table and asserts it.

The balance list's token-type gate is now case-insensitive. It compared
exactly, so an explorer writing "erc-20" would silently drop a real holding
before any filter ran. Which types are admitted is unchanged.
2026-08-12 10:12:28 +00:00
a08ba6a66d fix: filter the restored view stack against RESTORABLE_VIEWS (closes #224)
Some checks failed
check / check (push) Has been cancelled
The persisted view stack was restored verbatim. RESTORABLE_VIEWS stopped the
popup opening ONTO a view it will not re-render, but nothing kept such a view
out of the stack, so Back could land on a screen whose content was deliberately
never restored. No secret leaks -- those views are blank precisely because
nothing is restored into them; this is a navigation defect.

loadState() now truncates the stored stack at the first entry outside
RESTORABLE_VIEWS, dropping it and everything above it. Truncating rather than
splicing keeps the result a prefix of what was stored, so every surviving entry
keeps the Back target it had; splicing would silently re-point the entry above
the hole at a different screen. Filtering on load rather than on save is what
makes it retroactive for stacks already in storage, and leaves the live
in-session stack whole, which it should be.

The general case where Back lands on a blank screen even for restorable views,
because goBack() re-renders nothing, is separate and tracked at #268.
2026-08-12 12:07:48 +02:00
09b602579a fix: one password-failure message across every screen (closes #172)
Some checks failed
check / check (push) Has been cancelled
A rejected password was reported three different ways depending on which screen
you were on, including the fragment "Wrong password." which is not a sentence.
All six decryptWithPassword call sites now show the same full sentence.

Strings only -- a wrong password still fails closed on every screen and still
resolves no pending approval.

A test pins the invariant per call site: each decryptWithPassword call is walked
out to its enclosing try and forward to that block's catch, and the prose shown
there must equal the canonical sentence. Per-file matching was not enough, since
a file with two call sites kept passing while one of them diverged.
2026-08-12 12:03:39 +02:00
18b47cd579 test: close the empty-batch hole in the e2e unstubbed-request guard (closes #187)
Some checks failed
check / check (push) Has been cancelled
The guard that reports unrecognised POST bodies used batch.every(), which is
vacuously true on an empty array, so a POST with body [] was answered 200 []
and escaped the one mechanism whose job is to make unrecognised outbound
traffic fail the suite rather than pass silently. Unreachable in practice
today, which is exactly the qualifier that stops being true later.

The comment explaining the guard also described a mechanism that does not
exist: playwright-core decodes a binary body lossily rather than returning
null, so such a body reaches the JSON parse as mojibake and is reported by the
catch, while only an absent or empty body decodes to null and is reported by
the type guard. Both are reported; the comment now describes the two real
routes.
2026-08-12 11:50:38 +02:00
13 changed files with 748 additions and 16 deletions

36
TODO.md
View File

@@ -44,6 +44,42 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
format and default-ignorable character removed, then trimmed — so `" ETH "`, a
no-break space, a zero-width space, a Hangul filler, a variation selector and
a fullwidth `` are all caught on the balance list, the history and the
send selector at once. Confusables that are distinct letters (Cyrillic `Е`),
bidi reordering, the visible C0/C1 controls and U+007F stay knowingly open and
are asserted as open in the suite. No bundled symbol contains whitespace or a
non-ASCII character, so nothing legitimate is newly filtered; the balance
list's token-type gate also became case-insensitive, which no longer drops a
real holding if an explorer writes `erc-20`
([#260](https://git.eeqj.de/sneak/AutistMask/issues/260)).
- 2026-08-12: The restored navigation stack is filtered against
`RESTORABLE_VIEWS` on load, truncated at the first entry the popup would not
render so that every surviving entry keeps the Back target it had. Back after
reopening can no longer land on a view the popup declined to restore, such as
`export-privkey` or `show-phrase`
([#224](https://git.eeqj.de/sneak/AutistMask/issues/224)). Restorable views in
the stack are still unhidden without being re-rendered; that is tracked
separately in ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
- 2026-08-12: One wording for a rejected password on every screen that asks for
one — the send confirmation and the delete-wallet confirmation no longer say
"Wrong password." (a fragment, which `RULES.md` Language & Labeling forbids)
and the two reveal screens no longer say "not correct", so all five
`decryptWithPassword` call sites now show the sentence the dApp approval paths
introduced. Strings only, no behaviour change, and each error container
measured at a 360px viewport in the pinned Playwright container
([#172](https://git.eeqj.de/sneak/AutistMask/issues/172)).
- 2026-08-12: Closed the empty-array hole in the end-to-end unstubbed-request
guard. `batch.every()` is vacuously true on `[]`, so a POST with body `[]` was
answered `200 []` instead of failing the suite; the guard now rejects an empty
batch, demonstrated green-before/red-after with a throwaway probe. The comment
claiming `postData()` returns `null` for undecodable bodies was corrected to
the two real paths — an absent or empty body decodes to `null`, a binary body
decodes lossily into invalid JSON
([#187](https://git.eeqj.de/sneak/AutistMask/issues/187)).
- 2026-08-12: The transaction confirmation screen has browser coverage. The
end-to-end suite reaches ConfirmTx for both the native ETH and the ERC-20 path
off a funded-balance fixture, and asserts the pending, funded, over-balance

View File

@@ -422,7 +422,10 @@ function init(ctx) {
password,
);
} catch (e) {
showError("confirm-tx-password-error", "Wrong password.");
showError(
"confirm-tx-password-error",
"That password is incorrect. Please try again.",
);
return;
}

View File

@@ -74,7 +74,8 @@ function init(_ctx) {
try {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) {
$("delete-wallet-flash").textContent = "Wrong password.";
$("delete-wallet-flash").textContent =
"That password is incorrect. Please try again.";
$("delete-wallet-flash").style.visibility = "visible";
btn.disabled = false;
btn.classList.remove("text-muted");

View File

@@ -144,7 +144,7 @@ async function reveal() {
$("export-privkey-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
fail("That password is not correct. Please try again.");
fail("That password is incorrect. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");

View File

@@ -126,7 +126,7 @@ async function reveal() {
if (!isCurrentReveal(generation)) return;
// Deliberately not the caught error: the message is fixed so that
// nothing derived from the ciphertext or the attempt can surface.
fail("That password is not correct. Please try again.");
fail("That password is incorrect. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");

View File

@@ -66,7 +66,12 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
const balances = [];
for (const item of items) {
if (item.token?.type !== "ERC-20") continue;
// Case-insensitive: the token type is an explorer's label, not a
// protocol value, and an exact comparison silently drops a real
// holding if one ever writes "erc-20". Which types are admitted
// is unchanged.
const type = String(item.token?.type || "").toUpperCase();
if (type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue;

View File

@@ -2,6 +2,8 @@
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const storageApi =
typeof browser !== "undefined"
@@ -43,6 +45,39 @@ const state = {
viewStack: [],
};
// Keep only the leading run of stored views the popup is willing to render.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
//
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
// A stored stack that is missing or not an array keeps nothing, but it
// still goes through the never-empty rule below rather than returning
// early: otherwise a corrupt stack would depend on exactly the goBack()
// fallback that the explicit ["main"] exists in order not to depend on.
const source = Array.isArray(stored) ? stored : [];
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go.
if (
kept.length === 0 &&
currentView !== "main" &&
RESTORABLE_VIEWS.has(currentView)
) {
return ["main"];
}
return kept;
}
// Return the network configuration for the currently selected network.
function currentNetwork() {
return networkById(state.networkId);
@@ -150,7 +185,7 @@ async function loadState() {
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
state.selectedToken = saved.selectedToken || null;
state.viewData = saved.viewData || {};
state.viewStack = Array.isArray(saved.viewStack) ? saved.viewStack : [];
state.viewStack = restorableStack(saved.viewStack, state.currentView);
}
}

View File

@@ -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,52 @@ 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 \p{Cf} plus \p{Default_Ignorable_Code_Point}: the
// format characters (zero-width space, joiner and non-joiner,
// word joiner, soft hyphen, byte-order mark, bidi marks and
// overrides), the variation selectors, and the Hangul
// fillers. \p{Cf} alone is not the class of things that
// paint nothing — a Hangul filler is Lo and a variation
// selector is Mn, and both are as invisible as a zero-width
// space. 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 class is Unicode's, so what it covers is a definition rather than a
// measurement; measured in the repo's pinned e2e Chromium (16px sans-serif,
// plain `ETH` = 32.00px), every stripped character paints nothing except
// U+1160 and U+FFA0, which font fallback draws as a box. Stripping those
// two hides a token that does not look like the symbol, which is the
// harmless direction of the two.
//
// 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), and bidi reordering, which needs
// the bidi algorithm rather than a character filter. The C0/C1 controls are
// left alone because they render as a visible box (48.00px) — except U+007F,
// which measures 32.00px, i.e. invisible and still not caught. That one is
// a live gap, flagged rather than closed here because it is a control
// character rather than a default-ignorable one and the class to strip is a
// decision of its own.
//
// 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}]/gu, "")
.trim()
.toUpperCase();
}
// True when a token bearing `symbol` from contract `contractAddress` is
// impersonating a known symbol.
//
@@ -31,7 +82,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;

View File

@@ -297,17 +297,26 @@ async function handleRpc(route, postData, opts, report) {
// ethers batches by default, so the body may be an array.
const batch = Array.isArray(payload) ? payload : [payload];
// Anything that is not a JSON-RPC object, or a batch of them, is not
// RPC at all and must be reported like any other unrecognised
// outbound traffic rather than dereferenced. request.postData()
// returns null both for a bodyless POST and for a body Playwright
// cannot decode as UTF-8 (sendBeacon with a Blob, or any binary
// payload), so this is not an empty-string special case: it rejects
// every non-object payload, exactly as the catch above rejects every
// unparseable one.
// Anything that is not a JSON-RPC object, or a NON-EMPTY batch of
// them, is not RPC at all and must be reported like any other
// unrecognised outbound traffic rather than dereferenced.
//
// The length check is not decoration: every() is vacuously true on an
// empty array, so without it a POST with body [] was answered 200 []
// and escaped the guard entirely (issue #187). No real batch is empty,
// so nothing legitimate is caught by it.
//
// Two distinct paths land a non-RPC body here, and neither is an
// empty-string special case. playwright-core's postData() is
// `buffer.toString("utf-8") || null`, so an absent or empty body
// decodes to null, JSON.parse("null") yields null, and the type guard
// below reports it. A binary body is instead decoded LOSSILY into
// mojibake — not null — which is not valid JSON, so the catch above
// reports that one. Both end up reported; only the route differs.
if (
payload === null ||
typeof payload !== "object" ||
batch.length === 0 ||
!batch.every((req) => req !== null && typeof req === "object")
) {
report("unstubbed request: POST " + route.request().url());

View File

@@ -247,7 +247,7 @@ describe("a reveal that is not interrupted", () => {
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-flash").textContent).toBe(
"That password is not correct. Please try again.",
"That password is incorrect. Please try again.",
);
});
});

View File

@@ -0,0 +1,213 @@
// One wording for one condition (issue #172).
//
// Every screen that asks for the password decrypts the vault itself, and
// each one used to write its own sentence for the same failure: the send
// confirmation and the delete-wallet confirmation said "Wrong password."
// (a fragment, which RULES.md Language & Labeling forbids), the reveal
// screens said "That password is not correct.", and the two dApp approval
// paths said "That password is incorrect." A user hitting two of those
// minutes apart had no way to tell whether the wallet meant the same
// thing.
//
// This scans the source rather than driving six views, because the
// invariant is about the set of call sites and not about any one of them:
// a seventh screen that decrypts the vault has to join the set, and a
// DOM test per view cannot notice one that was never written.
//
// The assertions are per CALL SITE, not per file. approval.js decrypts in
// two places and is where the divergence came from; a per-file check that
// only asks whether the canonical sentence appears somewhere in the file
// passes while one of those two says something else entirely. So each
// call site is read back to its own catch handler and the prose that
// handler shows the user must be the canonical sentence and nothing else
// — which fails on a novel wording, not only on a known-superseded one.
const fs = require("fs");
const path = require("path");
const SRC = path.join(__dirname, "..", "src");
const CANONICAL = "That password is incorrect. Please try again.";
// Wordings this repo has actually shipped for the same condition. This is
// a secondary, whole-file sweep for stragglers outside a decrypt handler;
// divergence at a call site is caught by the exact-match assertion, which
// needs no list of phrasings to guess at.
const SUPERSEDED = [
"Wrong password.",
"That password is not correct. Please try again.",
];
function jsFilesUnder(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return jsFilesUnder(full);
return entry.name.endsWith(".js") ? [full] : [];
});
}
// Blank out the interior of every comment and string literal, keeping the
// offsets and line breaks, so braces can be counted without a quote or a
// commented-out block throwing the count off. The literals are returned
// alongside with the offset of their opening quote, which is how a
// message is later attributed to the handler it sits in.
function scan(source) {
const masked = source.split("");
const strings = [];
const blank = (from, to) => {
for (let k = from; k < to; k++) if (masked[k] !== "\n") masked[k] = " ";
};
let i = 0;
while (i < source.length) {
const two = source.slice(i, i + 2);
if (two === "//") {
const nl = source.indexOf("\n", i);
const stop = nl === -1 ? source.length : nl;
blank(i, stop);
i = stop;
} else if (two === "/*") {
const close = source.indexOf("*/", i + 2);
const stop = close === -1 ? source.length : close + 2;
blank(i, stop);
i = stop;
} else if (
source[i] === '"' ||
source[i] === "'" ||
source[i] === "`"
) {
const quote = source[i];
let j = i + 1;
let value = "";
while (j < source.length && source[j] !== quote) {
if (source[j] === "\\") {
value += source[j + 1];
j += 2;
continue;
}
value += source[j];
j += 1;
}
blank(i + 1, j);
strings.push({ offset: i, value });
i = j + 1;
} else {
i += 1;
}
}
return { masked: masked.join(""), strings };
}
// Offset of the `{` that opens the block containing `at`, or -1.
function enclosingBlockStart(masked, at) {
let depth = 0;
for (let i = at; i >= 0; i--) {
if (masked[i] === "}") depth += 1;
else if (masked[i] === "{") {
if (depth === 0) return i;
depth -= 1;
}
}
return -1;
}
// Offset just past the `}` matching the `{` at `open`.
function blockEnd(masked, open) {
let depth = 0;
for (let i = open; i < masked.length; i++) {
if (masked[i] === "{") depth += 1;
else if (masked[i] === "}") {
depth -= 1;
if (depth === 0) return i + 1;
}
}
throw new Error("unterminated block");
}
// The catch handler guarding a given decryptWithPassword call: walk out to
// the try block the call sits in, then take the catch that follows it.
function handlerSpan(masked, callOffset, label) {
const tryOpen = enclosingBlockStart(masked, callOffset);
if (tryOpen === -1 || !/\btry\s*$/.test(masked.slice(0, tryOpen)))
throw new Error(`${label}: the decrypt is not inside a try block`);
const rest = masked.slice(blockEnd(masked, tryOpen));
const catchMatch = /^\s*catch\s*(\([^)]*\)\s*)?\{/.exec(rest);
if (!catchMatch)
throw new Error(`${label}: the decrypt's try block has no catch`);
const catchOpen = blockEnd(masked, tryOpen) + catchMatch[0].length - 1;
return [catchOpen, blockEnd(masked, catchOpen)];
}
// The prose the handler puts in front of the user. Element ids, class
// names and visibility keywords are single words; a sentence has a space
// in it, and that is the whole distinction needed here.
function handlerMessages(file, callOffset, label) {
const { masked, strings } = scan(fs.readFileSync(file, "utf8"));
const [from, to] = handlerSpan(masked, callOffset, label);
return strings
.filter((s) => s.offset >= from && s.offset < to)
.map((s) => s.value)
.filter((v) => v.includes(" "));
}
// The call sites are found, not listed: the file layout moves (the private
// key export was in addressDetail.js when #172 was filed and is its own
// view now), and a hardcoded list would quietly stop covering a screen it
// no longer names.
function callSites() {
const sites = [];
for (const file of jsFilesUnder(SRC)) {
if (file === path.join(SRC, "shared", "vault.js")) continue;
const { masked } = scan(fs.readFileSync(file, "utf8"));
const rel = path.relative(SRC, file).split(path.sep).join("/");
let n = 0;
let at = masked.indexOf("decryptWithPassword(");
while (at !== -1) {
n += 1;
sites.push({ file, rel, offset: at, label: `${rel} #${n}` });
at = masked.indexOf("decryptWithPassword(", at + 1);
}
}
return sites.sort((a, b) => a.label.localeCompare(b.label));
}
describe("password failure messages", () => {
const sites = callSites();
const files = [...new Set(sites.map((s) => s.file))].sort();
test("the call sites are found where they are expected", () => {
const counts = {};
for (const site of sites)
counts[site.rel] = (counts[site.rel] ?? 0) + 1;
expect(counts).toEqual({
"popup/views/approval.js": 2,
"popup/views/confirmTx.js": 1,
"popup/views/deleteWallet.js": 1,
"popup/views/exportPrivkey.js": 1,
"popup/views/showPhrase.js": 1,
});
});
test("the canonical message is a full sentence", () => {
expect(CANONICAL).toMatch(/^[A-Z][^]*\.$/);
});
// Exact equality, per call site: a message that is merely different
// rather than known-obsolete fails here too, which a scan for historic
// wordings cannot do.
test.each(sites.map((s) => [s.label, s]))(
"%s answers a rejected password with the canonical sentence",
(label, site) => {
expect(handlerMessages(site.file, site.offset, label)).toEqual([
CANONICAL,
]);
},
);
test.each(files.map((f) => [path.relative(SRC, f), f]))(
"%s carries no superseded wording",
(_rel, file) => {
const source = fs.readFileSync(file, "utf8");
for (const old of SUPERSEDED) expect(source).not.toContain(old);
},
);
});

View File

@@ -159,3 +159,113 @@ describe("hideSpoofedSymbols persistence", () => {
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
});
});
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it was restored verbatim, so Back could still walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control of its own to leave by. The stack is filtered on load, at the
// first entry the popup would not render, and everything above it goes too:
// those entries were reached THROUGH the dropped one.
describe("restored viewStack is filtered against RESTORABLE_VIEWS", () => {
const NON_RESTORABLE = ["export-privkey", "show-phrase"];
function restoredStack(viewStack, currentView = "settings") {
return loadModuleWith({
wallets: oneWallet(),
currentView,
viewStack,
});
}
test("a non-restorable view at the top of the stack is dropped", async () => {
const { mod } = restoredStack(["main", "address", "export-privkey"]);
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main", "address"]);
});
test("a non-restorable view in the middle truncates the stack there", async () => {
const { mod } = restoredStack(["main", "show-phrase", "address"]);
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main"]);
});
// Truncating a stack rooted at a non-restorable view leaves nothing, and
// the restored view still needs somewhere for Back to go.
test("a non-restorable view at the bottom leaves main to go back to", async () => {
const { mod } = restoredStack(["export-privkey", "address", "receive"]);
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main"]);
});
test("no restored stack retains a secret-bearing view", async () => {
for (const view of NON_RESTORABLE) {
const { mod } = restoredStack(["main", "address", view, "receive"]);
await mod.loadState();
expect(mod.state.viewStack).not.toContain(view);
}
});
// The rule is "views the popup will render", not a blocklist of the two
// secret screens: a name no longer in the set (or never a view at all)
// has to go the same way.
test("a name that is not a restorable view at all is dropped", async () => {
const { mod } = restoredStack(["main", "welcome", "address"]);
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main"]);
});
// Restorable entries are kept verbatim. That they are then unhidden
// without being re-rendered is a separate defect, tracked in #268; this
// filter is only about views the popup declined to restore.
test("an ordinary restorable stack is restored unchanged", async () => {
const stack = ["main", "address", "address-token"];
const { mod } = restoredStack(stack);
await mod.loadState();
expect(mod.state.viewStack).toEqual(stack);
});
test("restoring onto main keeps the stack empty", async () => {
const { mod } = restoredStack(["show-phrase"], "main");
await mod.loadState();
expect(mod.state.viewStack).toEqual([]);
});
// main is not the only view that gets no ["main"] beneath it: restoreView()
// will not reopen onto a non-restorable view either, so nothing is left for
// Back to sit under and the stack stays empty.
test("restoring onto a view the popup will not reopen keeps the stack empty", async () => {
const { mod } = restoredStack(["export-privkey"], "show-phrase");
await mod.loadState();
expect(mod.state.viewStack).toEqual([]);
});
// Not an array means nothing survives, but the never-empty rule still
// applies: a corrupt stack must not leave a restored view with no Back
// target of its own.
test("a stack that is not an array still gets main beneath a restored view", async () => {
const { mod } = restoredStack("main");
await mod.loadState();
expect(mod.state.viewStack).toEqual(["main"]);
});
test("a stack that is not an array loads as empty under main", async () => {
const { mod } = restoredStack({ 0: "main" }, "main");
await mod.loadState();
expect(mod.state.viewStack).toEqual([]);
});
// Filtering belongs on load, not on save: the live in-session stack is
// legitimate — the user really is one Back away from a screen that is
// rendered right now — and only a load-side filter also cleans the
// stacks already sitting in storage.
test("saveState persists the live stack verbatim", async () => {
const { mod, set } = loadModuleWith(null);
mod.state.viewStack = ["main", "address", "export-privkey"];
await mod.saveState();
expect(set).toHaveBeenCalledWith({
autistmask: expect.objectContaining({
viewStack: ["main", "address", "export-privkey"],
}),
});
});
});

View File

@@ -124,6 +124,173 @@ describe("the shared rule", () => {
});
});
// Issue #260: the symbol is whatever the ERC-20 contract returns, and HTML
// collapses leading and trailing whitespace, so a token calling itself
// `" ETH "` reaches the user's eye as `ETH` while missing a raw
// KNOWN_SYMBOLS lookup. Normalizing inside the shared rule fixes all three
// surfaces at once, which is what consolidating the rule bought.
//
// Every character under test here is built from its code point rather than
// pasted in: most of them are invisible, and an invisible character in a
// test file is unreviewable.
const cp = (...codes) => String.fromCodePoint(...codes);
const NBSP = cp(0x00a0); // no-break space
const FIGURE_SPACE = cp(0x2007);
const IDEOGRAPHIC_SPACE = cp(0x3000);
const ZWSP = cp(0x200b); // zero-width space
const BOM = cp(0xfeff); // zero-width no-break space
const WORD_JOINER = cp(0x2060);
const SOFT_HYPHEN = cp(0x00ad);
const LRM = cp(0x200e); // left-to-right mark
const RLO = cp(0x202e); // right-to-left override
const HANGUL_FILLER = cp(0x3164);
const CHOSEONG_FILLER = cp(0x115f);
const VS16 = cp(0xfe0f); // variation selector-16
const VS1 = cp(0xfe00); // variation selector-1
const NEL = cp(0x0085); // next line, a C1 control
const DEL = cp(0x007f);
const FULLWIDTH_ETH = cp(0xff25, 0xff34, 0xff28);
const FULLWIDTH_USDC = cp(0xff55, 0xff53, 0xff44, 0xff43); // lowercase
const CYRILLIC_CAPITAL_IE = cp(0x0415);
describe("the shared rule: symbols that render as a known symbol", () => {
test("ASCII padding does not buy a pass", () => {
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
});
test("non-breaking and other Unicode spaces do not either", () => {
expect(isSpoofedSymbol(NBSP + "ETH" + NBSP, FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(
isSpoofedSymbol(
FIGURE_SPACE + "ETH" + IDEOGRAPHIC_SPACE,
FAKE_ETH_CONTRACT,
),
).toBe(true);
});
// These render as nothing at all, in any position, so they are removed
// wherever they sit rather than only at the ends.
test("zero-width characters are stripped wherever they sit", () => {
expect(isSpoofedSymbol("E" + ZWSP + "TH", FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(isSpoofedSymbol(BOM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
expect(
isSpoofedSymbol("ET" + WORD_JOINER + "H", FAKE_ETH_CONTRACT),
).toBe(true);
expect(
isSpoofedSymbol("E" + SOFT_HYPHEN + "TH", FAKE_ETH_CONTRACT),
).toBe(true);
});
// An LRM is invisible and, in all-Latin text, moves nothing: dropping it
// leaves exactly the string the user saw.
test("an invisible bidi mark does not hide a known symbol", () => {
expect(isSpoofedSymbol(LRM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
});
// Invisibility is not confined to \p{Cf}. A Hangul filler is Lo and a
// variation selector is Mn, yet each of these four measures 32.00px in
// the repo's pinned e2e Chromium at 16px sans-serif — exactly the width
// of a plain `ETH` — so each reaches the user's eye as `ETH`. They are
// caught by \p{Default_Ignorable_Code_Point}, not by \p{Cf}.
test("invisible non-format characters are stripped too", () => {
expect(isSpoofedSymbol(HANGUL_FILLER + "ETH", FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(
isSpoofedSymbol(CHOSEONG_FILLER + "ETH", FAKE_ETH_CONTRACT),
).toBe(true);
expect(isSpoofedSymbol("ETH" + VS16, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("E" + VS1 + "TH", FAKE_ETH_CONTRACT)).toBe(true);
});
// The strip stops at default-ignorable and must not creep past it: the
// C0 and C1 controls render as a visible 48.00px box in the same
// browser, so a symbol carrying one does not look like `ETH` and must
// not be judged a spoof.
test("visible control characters do not make a symbol a spoof", () => {
expect(isSpoofedSymbol(NEL + "ETH", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(cp(0x0001) + "ETH", FAKE_ETH_CONTRACT)).toBe(
false,
);
expect(isSpoofedSymbol(cp(0x0090) + "ETH", FAKE_ETH_CONTRACT)).toBe(
false,
);
});
// Known gap, recorded rather than endorsed: U+007F is a control (Cc),
// not default-ignorable, so the strip does not reach it — but unlike
// the rest of its class it measures 32.00px, i.e. it paints nothing.
// Closing it means picking a rule for the controls, which is a decision
// of its own; this assertion is here so the gap cannot be forgotten.
test("U+007F renders as nothing and is knowingly still not caught", () => {
expect(isSpoofedSymbol(DEL + "ETH", FAKE_ETH_CONTRACT)).toBe(false);
});
test("compatibility forms fold onto the symbol they imitate", () => {
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
});
// The two knowingly open classes, asserted here so that the boundary is
// a fact in the suite and not a claim in a PR body. A Cyrillic capital
// Ie is a distinct letter rather than a compatibility variant, so NFKC
// leaves it alone; and a right-to-left override reverses the rendering
// of what follows it, which dropping the control character does not
// undo. Closing either needs a confusables table or a bidi resolver,
// and both are a separate change from this one.
test("a Cyrillic homoglyph is knowingly still not caught", () => {
expect(
isSpoofedSymbol(CYRILLIC_CAPITAL_IE + "TH", FAKE_ETH_CONTRACT),
).toBe(false);
});
test("a bidi-reordered symbol is knowingly still not caught", () => {
expect(isSpoofedSymbol(RLO + "HTE", FAKE_ETH_CONTRACT)).toBe(false);
});
// Normalization does not reach the native-asset exemption, which turns
// on the absence of a contract address and never on the symbol.
test("a padded symbol with no contract is still not a spoof", () => {
expect(isSpoofedSymbol(" ETH ", null)).toBe(false);
expect(isSpoofedSymbol(NBSP + "ETH", "")).toBe(false);
});
test("a genuine contract still bears its own padded symbol", () => {
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
});
// Normalization must not invent a match. Interior ASCII whitespace is
// left alone: `E T H` renders as `E T H`, not as `ETH`, so folding it
// would filter a token no user could confuse with the native asset.
test("a symbol that renders differently is not judged a spoof", () => {
expect(isSpoofedSymbol("E T H", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("ETH2", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("MY ETH", FAKE_ETH_CONTRACT)).toBe(false);
});
// The false-positive question, answered against the shipped data rather
// than by assertion: no bundled symbol carries whitespace or a
// non-ASCII character, so the normalization cannot newly filter one.
// The character class starts at `!` rather than at the space so that it
// asserts the claim it stands for — `[ -~]` would admit an interior
// space and let a whitespace-bearing entry through the guard.
test("no bundled symbol is touched by the normalization", () => {
for (const [symbol, address] of KNOWN_SYMBOLS) {
expect(symbol).toBe(symbol.trim());
expect(symbol).toMatch(/^[!-~]+$/);
if (address === null) continue;
expect(isSpoofedSymbol(symbol, address)).toBe(false);
}
});
});
describe("surface 1: the transaction history", () => {
function fakeEthTransfer() {
return {
@@ -147,6 +314,22 @@ describe("surface 1: the transaction history", () => {
expect(result.transactions).toEqual([]);
});
// Issue #260 on this surface: the same transfer with a padded symbol.
test("a padded fake ETH token transfer is filtered too", () => {
const padded = { ...fakeEthTransfer(), symbol: " ETH " };
const result = filterTransactions([padded], {
hideSpoofedSymbols: true,
hideFraudContracts: true,
hideLowHolderTokens: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
});
expect(result.transactions).toEqual([]);
// The contract is learned as fraudulent, exactly as for the
// unpadded symbol: the padding must not cost the blocklist entry.
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
test("a real native ETH transfer survives", () => {
const native = {
hash: "0x" + "2".repeat(64),
@@ -201,6 +384,36 @@ describe("surface 2: the Send token selector", () => {
expect(select.children).toEqual([]);
});
// Issue #260 on this surface: the option text is rendered into HTML,
// which collapses the padding, so an unfiltered padded token would sit
// in the selector reading exactly `ETH`.
test("a padded fake ETH token is not selectable either", () => {
render([
{
address: FAKE_ETH_CONTRACT,
symbol: " ETH ",
decimals: 18,
balance: "0.005",
holders: 900000,
},
]);
expect(select.children).toEqual([]);
});
test("a genuine token with a padded symbol stays selectable", () => {
render([
{
address: USDC_CONTRACT,
symbol: " USDC ",
decimals: 6,
balance: "12.5",
holders: 900000,
},
]);
expect(select.children).toHaveLength(1);
expect(select.children[0].value).toBe(USDC_CONTRACT);
});
test("native ETH remains the always-present option", () => {
render([]);
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
@@ -251,6 +464,35 @@ describe("surface 3: the balance list", () => {
expect(balances).toEqual([]);
});
// Issue #260 on this surface: the balance list is where the user forms
// their belief about what they own, and it renders the symbol into HTML.
test("a padded fake ETH token is filtered too", async () => {
respondWith([fakeEthItem({ symbol: " ETH " })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
test("a fake ETH token padded with a no-break space is filtered", async () => {
respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The false-positive direction on the surface that matters most: a real
// holding whose symbol happens to carry padding is still listed, and the
// list still shows the symbol the token actually reports.
test("a genuine token with a padded symbol is not newly filtered", async () => {
respondWith([
fakeEthItem({
address_hash: USDC_CONTRACT,
symbol: " USDC ",
name: "USD Coin",
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC ");
});
test("a genuine token keeps its place in the list", async () => {
respondWith([
fakeEthItem({
@@ -274,6 +516,33 @@ describe("surface 3: the balance list", () => {
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The adjacent finding from the same review as issue #260: the type gate
// compared exactly, so an explorer that ever varied the casing would
// silently drop a real holding before any filter ran. The comparison is
// now case-insensitive, which changes nothing about which types are
// admitted.
test("a differently-cased ERC-20 type still lists a real holding", async () => {
respondWith([
fakeEthItem({
type: "erc-20",
address_hash: USDC_CONTRACT,
symbol: "USDC",
name: "USD Coin",
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
test("case insensitivity does not admit another token type", async () => {
respondWith([fakeEthItem({ type: "erc-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The money test: the user holds real ETH and has been airdropped a fake
// ETH ERC-20. The fake is gone from the list of tokens; the real balance
// is exactly what the node reported.