Compare commits
4 Commits
13bf482327
...
ada643cb44
| Author | SHA1 | Date | |
|---|---|---|---|
| ada643cb44 | |||
| a08ba6a66d | |||
| 09b602579a | |||
| 18b47cd579 |
36
TODO.md
36
TODO.md
@@ -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 `ETH` 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
213
tests/passwordMessages.test.js
Normal file
213
tests/passwordMessages.test.js
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user