Compare commits

..

4 Commits

Author SHA1 Message Date
211ac4779b fix: gate the chain switch and remember endpoints per network (closes #308)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 23s
wallet_switchEthereumChain was answered for any origin at all, with no
connection check and no prompt, so a page the user had never connected to
could move the active chain — clearing the [TESTNET] banner under someone
who believed they were on Sepolia. It now takes the same
allowedSites/connectedSites gate the signing methods take, ahead of the
same-chain and unsupported-chain answers, and refuses an unconnected origin
with 4100.

The switch also overwrote state.rpcUrl and state.blockscoutUrl with the
network defaults, so a user running their own node lost that url
permanently and silently to a public endpoint that then sees every address
they hold. Endpoints are now remembered per network in
state.networkEndpoints: the switch snapshots the network being left and
restores the network being entered, falling back to that network's
defaults. state.rpcUrl and state.blockscoutUrl remain the live endpoints of
the active network, so no reader changed; for the active network they are
authoritative and the map entry may be stale, and the snapshot is what
reconciles them. A profile written before the map existed has its stored
pair adopted for the network it was stored under, so nothing is lost on
first load.

Neither half held without loading state first. onChainSwitch() mutates the
module-level state singleton and persists every field of it, and
currentNetwork() reads the same singleton, but the service worker populates
nothing at module scope — a worker revived by the page's own message held
DEFAULT_STATE, so the same-chain check compared against the wrong network
and the save wrote empty wallets, empty allowedSites, no tracked tokens and
the default endpoints over the user's stored profile. The handler now
awaits loadState() after the gate, as the transaction path already does.

A stored networkEndpoints must now be an actual object. The previous guard
discarded only falsy values and arrays, so a stored primitive survived the
load, the seeding assignment silently no-opped on it, saveState()
re-persisted it, and every switch fell back to the public default in place
of the user's endpoint — permanently, with no self-healing.
2026-08-20 10:38:52 +00:00
2f80a9bdb4 fix: sign the ERC-20 amount the confirmation screen displayed (closes #305)
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s
The send screen was built from the indexer's decimals while the transfer was
encoded from the contract's decimals() read at signing time, with nothing
comparing them. A token whose scales disagree moved 10^12 times the approved
amount.

The displayed scale is now carried on pendingTx from the same tokenBalances
entry the amount, balance and symbol were rendered from, and both encode sites
use it. transferAmount.js refuses rather than falling back when the two scales
disagree or either is unusable.

Adds the first end-to-end coverage of the popup's own Send -> ConfirmTx ->
Sign & Send path; #btn-confirm-send had never been clicked by any test.
2026-08-20 12:31:28 +02:00
ff3387d8cf feat: vendor and censor the phishing blocklist at build time (closes #219)
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 21s
2026-08-17 10:05:56 +02:00
8fcdd8a053 fix: settle a site approval on the port that carries its teardown (closes #275)
All checks were successful
check / check (push) Successful in 38s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 23s
2026-08-17 09:34:07 +02:00
34 changed files with 2665 additions and 232517 deletions

View File

@@ -682,7 +682,14 @@ under their own licenses. They are NOT covered by the GPL-3.0 license above.
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
File: src/shared/phishingBlocklist.json File: src/shared/phishingBlocklist.json
Source: https://github.com/AugurProject/eth-phishing-detect (config.json) Source: the eth-phishing-detect community blocklist (src/config.json).
The file here is derived from it, not a copy of it: only the
blacklist is carried over, and each entry is stored as a truncated
digest rather than a domain name. script/vendor-blocklist records
the exact upstream URL, the commit it is pinned to and the hash of
the bytes that commit serves, and is what regenerates this file.
The URL previously cited here, under a different organisation,
returns 404: that repository is gone.
Copyright: Copyright (c) 2018 kumavis Copyright: Copyright (c) 2018 kumavis
License: Don't Be a Dick Public License (DBAD), Version 1.2 License: Don't Be a Dick Public License (DBAD), Version 1.2
--------------------------------------------------------------------------- ---------------------------------------------------------------------------

View File

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check docker hooks build build-debug verify-build clean dev .PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug verify-build vendor-blocklist clean dev
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -35,6 +35,12 @@ fmt-check:
check: check:
@script/check @script/check
# Assert that the competitor name appears nowhere but its documented
# exceptions. Part of check, and re-run against dist/ at the end of a build;
# separate target for re-running it alone.
check-censored:
@script/check-censored
docker: docker:
@script/docker @script/docker
@@ -45,6 +51,7 @@ build:
@echo "Building extension..." @echo "Building extension..."
@yarn run build 2>&1 @yarn run build 2>&1
@script/verify-build @script/verify-build
@script/check-censored --require-dist
# Development-only build: enables the red DEBUG / INSECURE banner and makes # Development-only build: enables the red DEBUG / INSECURE banner and makes
# the hardcoded test recovery phrase the output of wallet creation. Never # the hardcoded test recovery phrase the output of wallet creation. Never
@@ -53,12 +60,19 @@ build-debug:
@echo "Building extension (DEBUG)..." @echo "Building extension (DEBUG)..."
@AUTISTMASK_DEBUG=1 yarn run build 2>&1 @AUTISTMASK_DEBUG=1 yarn run build 2>&1
@AUTISTMASK_DEBUG=1 script/verify-build @AUTISTMASK_DEBUG=1 script/verify-build
@script/check-censored --require-dist
# Assert the compiled DEBUG state of the bundles already in dist/. Runs at # Assert the compiled DEBUG state of the bundles already in dist/. Runs at
# the end of build and build-debug; separate target for re-running it alone. # the end of build and build-debug; separate target for re-running it alone.
verify-build: verify-build:
@script/verify-build @script/verify-build
# Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream.
# Run deliberately, land the diff: the extension does no runtime fetching, so
# the shipped list is as fresh as the last vendoring run that was released.
vendor-blocklist:
@script/vendor-blocklist
clean: clean:
@rm -rf dist/ @rm -rf dist/

185
README.md
View File

@@ -18,9 +18,10 @@ don't implement any crypto, and don't send user-specific data anywhere but a
extension contacts three user-configurable services: the configured RPC node for extension contacts three user-configurable services: the configured RPC node for
blockchain interactions, a public CoinDesk API (no API key) for realtime price blockchain interactions, a public CoinDesk API (no API key) for realtime price
information, and a Blockscout block-explorer API for transaction history and information, and a Blockscout block-explorer API for transaction history and
token balances. It also fetches a community-maintained phishing domain blocklist token balances. It also performs best-effort Etherscan address label lookups
periodically and performs best-effort Etherscan address label lookups during during transaction confirmation. A community-maintained phishing domain
transaction confirmation. blocklist is built into the extension at build time and checked locally; nothing
is fetched for it at runtime.
In the extension is a hardcoded list of the top ERC20 contract addresses. You In the extension is a hardcoded list of the top ERC20 contract addresses. You
can add any ERC20 contract by contract address if you wish, but the hardcoded can add any ERC20 contract by contract address if you wish, but the hardcoded
@@ -98,7 +99,21 @@ provide:
the same script lint in place instead of recursing. the same script lint in place instead of recursing.
- `script/fmt` — format all files (writes) - `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, test-verify-build, lint, and fmt-check - `script/check` — run test, test-verify-build, check-censored, lint, and
fmt-check
- `script/check-censored` — assert the competitor name RULES.md bars appears
nowhere in the working tree or under `dist/` outside its documented
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
fails anywhere else. Part of `make check`, which inspects `dist/` when there
is one and says loudly when there is not; `make build` re-runs it with
`--require-dist`, so a build artifact is always covered
- `script/vendor-blocklist` — refresh `src/shared/phishingBlocklist.json` from
its upstream, pinned to a commit and to the sha256 of the bytes that commit
serves. Run deliberately, never as part of a build: the output is committed
and there is no runtime fetch, so the shipped list is as fresh as the last
vendoring run that was released
- `script/verify-build` — assert the compiled `DEBUG` state of the bundles in - `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG` `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
@@ -238,16 +253,16 @@ That interception covers the MV3 background service worker as well as the popup
page, which it does not by default — `script/test-e2e` sets page, which it does not by default — `script/test-e2e` sets
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is `PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
experimental, the harness does not take it on trust. At launch it waits for the experimental, the harness does not take it on trust. At launch it waits for the
background worker's **own** startup request — the phishing blocklist fetch that background worker to exist, asks it for one throwaway `fetch()` of its own, and
`src/background/index.js` issues on startup, which on the suite's throwaway requires that request to arrive in the route handler within 30 seconds or aborts
profile always happens because no previous fetch timestamp is persisted — to the entire suite (`tests/e2e/harness.js`). The anchor used to be the worker's
arrive in the route handler, and aborts the entire suite if none does within 30 own startup traffic — the phishing blocklist fetch — and there is no longer any:
seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic the blocklist is vendored at build time and the extension contacts nobody when
probe fetched from inside the worker via `worker.evaluate()` was tried first and it starts. An earlier synthetic probe was rejected because evaluating in an
rejected, because evaluating in an extension service worker that early kills the extension service worker immediately after launch killed the worker outright;
worker outright, destroying the thing being measured. Observing traffic the waiting for the worker to be handed over first, and issuing a `fetch()` that is
extension already generates perturbs nothing. Losing the race fails closed — the not awaited, does not. Failing the probe fails closed — the suite refuses to run
suite refuses to run rather than passing quietly. rather than passing quietly.
As defence in depth, Chrome is also started with As defence in depth, Chrome is also started with
`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past `--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past
@@ -496,60 +511,46 @@ on the next event. Two consequences shape every recurring job in the background:
- `setInterval` and `setTimeout` are useless. They are destroyed with the - `setInterval` and `setTimeout` are useless. They are destroyed with the
worker, so a job scheduled that way runs until the first idle period and never worker, so a job scheduled that way runs until the first idle period and never
again. Both recurring jobs — the 60-second balance refresh and the 24-hour again. The one recurring job — the 60-second balance refresh — is scheduled
phishing blocklist refresh — are scheduled through the extension alarms API through the extension alarms API (`src/shared/alarms.js`) instead. The browser
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the holds the schedule and wakes the worker to deliver it. Alarm periods are
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so clamped to a one-minute minimum, so the balance refresh is expressed as
the balance refresh is expressed as exactly one minute and nothing is silently exactly one minute and nothing is silently slowed down.
slowed down.
- Module-level variables do not survive either. Anything that must be remembered - Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage, including the timestamp of the across a restart goes in extension storage. `localStorage` does not exist in a
last phishing list fetch: without it a revived worker would either re-fetch on service worker at all — the one remaining user of it, `src/shared/ens.js`,
every wake or, with a naive in-memory guard, never notice that an update is runs only in the popup and is marked as such.
due. `localStorage` does not exist in a service worker at all — the one
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
marked as such.
Both jobs also carry a freshness guard, and a guard must never be timed to the The job also carries a freshness guard, and a guard must never be timed to the
alarm period it gates. Each guard is measured from the moment the last run alarm period it gates. The guard is measured from the moment the last run
finished, which is one run-duration after the alarm that started it, so a guard finished, which is one run-duration after the alarm that started it, so a guard
of exactly one period vetoes the very next tick and the real cadence becomes two of exactly one period vetoes the very next tick and the real cadence becomes two
periods. The two jobs solve this differently, because their guards exist for periods. The balance refresh guard exists to skip work an open popup has already
different reasons: done — the popup refreshes every 10 seconds and stamps the same field — and that
has to keep applying on the scheduled tick, so the guard is shortened to half
- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker the alarm period rather than bypassed: comfortably above the popup's 10 seconds,
off the network on the wakes between scheduled refreshes — Chrome revives the so an open popup still suppresses the background job, and comfortably below the
worker every ~30 seconds while the browser is busy, and every revival runs the
startup path. The scheduled alarm tick is not one of those wakes, so it
bypasses the TTL and fetches unconditionally. Shortening the TTL instead would
not work: the startup path re-checks it on every wake, so a shorter TTL simply
becomes the real refresh rate.
- The balance refresh guard exists to skip work an open popup has already done —
the popup refreshes every 10 seconds and stamps the same field. That has to
keep applying on the scheduled tick, so the guard is shortened to half the
alarm period instead of bypassed: comfortably above the popup's 10 seconds, so
an open popup still suppresses the background job, and comfortably below the
60-second period, so the schedule always wins. 60-second period, so the schedule always wins.
Two timestamps are persisted for the phishing list, not one. `lastFetchTime` Retiring a job means clearing its alarm, not just deleting its handler. The
records a fetch that produced a usable delta and drives the TTL. browser keeps an alarm until something removes it, so an install that once ran
`lastAttemptTime` records that the network was contacted at all, and is written the version which created it goes on being woken on that schedule forever. Names
even when the result is unusable — a failed request, or a delta over the 256 KiB that are no longer handled are listed in `OBSOLETE_ALARMS` and cleared on every
cap. Without it those cases leave no freshness mark and the worker re-downloads start; the 24-hour phishing blocklist refresh is there, retired when the runtime
the full blocklist on every wake, indefinitely; with it, unscheduled retries are fetch was removed.
floored at one hour. Both are discarded on load if they are in the future, since
a stamp from a skewed clock or a restored backup would otherwise suppress
updates until that time arrives, permanently and with no way out.
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on The startup path (`ensureRecurringAlarms()`) runs on `onInstalled`, on
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way `onStartup`, and at the top level of the worker, so every way the background
the background context can start re-establishes the schedule. On a fresh install context can start re-establishes the schedule. On a fresh install more than one
more than one of those fires, so they share a single in-flight run rather than of those fires, so they share a single in-flight run rather than racing. It is
racing. It is idempotent: an alarm that already exists with the period the code idempotent: an alarm that already exists with the period the code asks for is
asks for is left alone, because re-creating one restarts its schedule and a busy left alone, because re-creating one restarts its schedule and a busy extension
extension would push the next fire out indefinitely. An alarm carrying a would push the next fire out indefinitely. An alarm carrying a different period
different period — one created by an earlier version — is re-created once, or a — one created by an earlier version — is re-created once, or a period changed in
period changed in a new release would never reach an existing install. a new release would never reach an existing install.
Nothing is fetched when the worker starts. A wake costs no network traffic at
all, which is what the phishing blocklist being vendored at build time bought.
Firefox uses Manifest V2 with a persistent background page, where timers would Firefox uses Manifest V2 with a persistent background page, where timers would
survive. Both browsers are built from one bundle and both take the alarm path, survive. Both browsers are built from one bundle and both take the alarm path,
@@ -991,6 +992,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- **Transitions**: - **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx** - "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx** - "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
- "Sign & Send" on an ERC-20 whose contract answers `decimals()` with a
different number than the amount above was displayed with → nothing is
signed → **ErrorTx** naming both numbers. The transfer is encoded from the
decimals the screen rendered, carried forward on the pending transaction;
the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" (wrong password) → "Wrong password." on the password error - "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change line, no screen change
- "Back" → **Send** - "Back" → **Send**
@@ -1412,17 +1420,6 @@ What the extension does NOT do:
In addition to the three user-configurable services above (RPC endpoint, In addition to the three user-configurable services above (RPC endpoint,
CoinDesk price API, and Blockscout API), AutistMask also contacts: CoinDesk price API, and Blockscout API), AutistMask also contacts:
- **Phishing domain blocklist**: A community-maintained phishing domain
blocklist is vendored into the extension at build time. At runtime, the
extension fetches the live list once every 24 hours to detect newly added
domains, plus once on a start where the list is more than 24 hours old. Only
the delta (domains not already in the vendored list) is kept in memory,
keeping runtime memory usage small. The delta and the timestamp of the fetch
that produced it are persisted to extension storage if the record is under 256
KiB; an oversized delta is dropped along with its timestamp, so a later start
fetches again rather than claiming freshness for data it no longer holds. A
fetch that fails, or one whose delta was too large to store, is not retried
more than once an hour outside the 24-hour schedule.
- **Etherscan address labels**: When confirming a transaction, the extension - **Etherscan address labels**: When confirming a transaction, the extension
performs a best-effort lookup of the recipient address on Etherscan to check performs a best-effort lookup of the recipient address on Etherscan to check
for phishing/scam labels. This is a direct page fetch with no API key; the for phishing/scam labels. This is a direct page fetch with no API key; the
@@ -1693,17 +1690,25 @@ indexes it as a real token transfer.
AutistMask protects users from known phishing sites when they connect their AutistMask protects users from known phishing sites when they connect their
wallet or approve transactions/signatures. A community-maintained domain wallet or approve transactions/signatures. A community-maintained domain
blocklist is vendored into the extension at build time, providing immediate blocklist is vendored into the extension at build time and checked entirely
protection without any network requests. At runtime, the extension fetches the locally: no network request is made for it, ever, so nobody learns which sites
live list once every 24 hours and keeps only the delta (newly added domains not the user connects to and no third party decides what this wallet warns about.
in the vendored list) in memory. This architecture keeps runtime memory usage
small while ensuring fresh coverage of new phishing domains.
The 24-hour cadence is an alarm, not a timer; the alarm tick fetches The trade is freshness. The shipped list is exactly as current as the last
unconditionally rather than re-checking the 24-hour cache TTL that gates the vendoring run that was released, so a domain added upstream reaches users in the
startup path; and the fetch timestamps live in extension storage rather than in next release rather than within a day. Refreshing it is `make vendor-blocklist`,
module variables — see [Background scheduling](#background-scheduling) for why which fetches a hash-pinned upstream commit, verifies the sha256 of the bytes it
all three are required. was served, and rewrites `src/shared/phishingBlocklist.json`; the diff is
committed and ships with the next version.
The artifact holds digests, not domain names: sha256 truncated to 64 bits, one
entry per 16 hex characters, concatenated in sorted order into a single string
(`src/shared/domainHash.js`). A lookup hashes the hostname and its parent
domains and binary-searches that string, so nothing is built at module load —
which matters on MV3, where the worker re-evaluates the module on every wake —
and the file is 1.7 MB rather than 8.7 MB. Storing digests is also what makes a
list assembled elsewhere shippable here at all: the extension carries no
plaintext list of anyone's domain names.
When a dApp on a blocklisted domain requests a wallet connection, transaction When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning approval, or signature, the approval popup displays a prominent red warning
@@ -1800,17 +1805,23 @@ covered by the GPL-3.0 license above. These files, their copyright holders, and
their licenses are: their licenses are:
| File | Source | Copyright | License | | File | Source | Copyright | License |
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) | | `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, derived from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT | | `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT | | `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
The full license texts for these third-party files are included in the The full license texts for these third-party files are included in the
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository [LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
link because the upstream is hosted under a competitor's organization name, link because the upstream is hosted under a competitor's organization name,
which project policy keeps out of code and documentation; the vendored copy and which project policy keeps out of code and documentation.
the runtime refresh both come from that upstream, whose URL is the `script/vendor-blocklist` is the single definition and the only file that spells
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`. the name in prose: it is build-time tooling, never shipped, and it records the
exact URL, the commit it is pinned to and the sha256 of the bytes that commit
serves, because a source reference nobody can verify is not a source reference.
`script/check-censored` reads the name back out of that one file and fails the
build wherever else it appears, save for three shipped-code literals it cannot
avoid — each permitted only at the one path that carries it, and listed in that
script's header.
## Author ## Author

73
TODO.md
View File

@@ -44,6 +44,49 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-08-20: A web page can no longer switch the wallet's chain, and switching
no longer destroys the user's endpoints
([#308](https://git.eeqj.de/sneak/AutistMask/issues/308)).
`wallet_switchEthereumChain` was answered for any origin at all, with no
connection check and no prompt: any page could clear the `[TESTNET]` banner
under a user who believed they were on Sepolia. It now takes the same
`allowedSites`/`connectedSites` gate the signing methods take, ahead of the
same-chain and unsupported-chain answers, and refuses an unconnected origin
with `4100`. The switch itself also overwrote `state.rpcUrl` and
`state.blockscoutUrl` with the network defaults, so a user running their own
node lost that url permanently and silently to a public endpoint that then
sees every address they hold. Endpoints are now remembered per network in
`state.networkEndpoints`, snapshotted from the network being left and restored
for the network being entered; `state.rpcUrl` stays the live value for the
active network, so no reader changed. A profile written before the map existed
has its stored pair adopted for the network it was stored under, and loses
nothing. The handler now loads state before it switches
([#316](https://git.eeqj.de/sneak/AutistMask/issues/316)): the service worker
populates nothing at module scope, so a worker revived by the page's own
message held `DEFAULT_STATE`, and the switch persisted every field of it —
wiping every wallet, every site approval and every tracked token from storage
along with the endpoint.
- 2026-08-20: The wallet's own ERC-20 send signs the amount it displayed
([#305](https://git.eeqj.de/sneak/AutistMask/issues/305)). The confirmation
screen renders from the block explorer's cached decimals; the transfer was
encoded from `decimals()` read off the contract at signing time, and nothing
compared the two, so a token whose on-chain scale disagreed — an upgradeable
or proxy token, a stale explorer entry, a compromised Blockscout — signed an
amount that was never on screen, off by a power of ten per decimal place of
disagreement. The scale is now carried forward on the pending transaction from
the same balance entry the screen's amount, balance and symbol come from, and
the contract's answer is read at signing time only to be compared with it: a
disagreement is a refusal naming both numbers, never a preference for either
(`src/shared/transferAmount.js`, the `confirmTx` counterpart to
`approvalVerify.js`). The gas estimate encodes from the same carried value and
no longer reads `decimals()` at all. Nothing in the e2e suite had ever clicked
`#btn-confirm-send`, which is how this shipped: the popup's own Send →
ConfirmTx → Sign & Send → WaitTx path now runs end to end to a broadcast, with
the `transfer()` amount decoded out of the raw signed bytes and asserted
against what the screen displayed, and a companion case where the contract
starts answering a different scale after the screen was built and nothing
reaches the RPC. Reverting only the signing-side comparison turns that second
case red and leaves the other 53 green.
- 2026-08-17: The Settings screen is driven in a browser, and every element id - 2026-08-17: The Settings screen is driven in a browser, and every element id
the popup looks up is checked statically. Nothing exercised Settings in the the popup looks up is checked statically. Nothing exercised Settings in the
e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups
@@ -72,6 +115,29 @@ but the review is broader than any of them.
the deletion of both persisted-value assignments in `settings.js` (only the the deletion of both persisted-value assignments in `settings.js` (only the
selector round-trip case red) selector round-trip case red)
([#229](https://git.eeqj.de/sneak/AutistMask/issues/229)). ([#229](https://git.eeqj.de/sneak/AutistMask/issues/229)).
- 2026-08-17: The phishing blocklist is vendored at build time and censored, and
the runtime fetch is gone
([#219](https://git.eeqj.de/sneak/AutistMask/issues/219)).
`script/vendor-blocklist` fetches upstream at a pinned commit, verifies the
sha256 of the bytes it was served, and writes
`src/shared/phishingBlocklist.json` as truncated sha256 digests rather than
domain names — which is what removes the competitor's name from a list that
carried it 6,475 times, without dropping a single one of those domains.
`script/check-censored` runs in `make check` and again against `dist/` at the
end of every build, each permitted occurrence scoped to the one path allowed
to carry it; the name now appears only in the vendoring script, which defines
it once, in the provider-shim identifiers in `src/content/inpage.js`, and in
one ERC-20's on-chain name in `src/shared/tokenList.js`. Removing the fetch
retired the delta, the persistence and the 24-hour alarm from
[#158](https://git.eeqj.de/sneak/AutistMask/issues/158), and retired alarms
are now cleared rather than left running on existing installs. Two
consequences, both deliberate: the list no longer self-updates, so it is as
fresh as the last vendoring run that was released; and re-vendoring from
current upstream took it from 231,357 stale entries to 105,721 current ones,
because upstream prunes and the vendored snapshot never did. `dist/` fell from
18.9 MB to 8.9 MB. The e2e suite now drives the warning end to end from a real
blocklisted origin, and its service-worker interception canary has a new
anchor, because the startup fetch it used to watch for no longer exists.
- 2026-08-17: One wording for an empty password field on every screen that asks - 2026-08-17: One wording for an empty password field on every screen that asks
for one. The private key export screen said "Password is required." where the for one. The private key export screen said "Password is required." where the
other five say "Please enter your password.", the same one-condition-two- other five say "Please enter your password.", the same one-condition-two-
@@ -100,9 +166,10 @@ but the review is broader than any of them.
and coverage change, not a repair of a broken target. `storageGet()` and and coverage change, not a repair of a broken target. `storageGet()` and
`storageSet()` **reject** where `storage.local` is absent rather than `storageSet()` **reject** where `storage.local` is absent rather than
resolving `{}` and a no-op write — they carry the wallet, and defaulting would resolving `{}` and a no-op write — they carry the wallet, and defaulting would
read an existing wallet back as none. The one caller that genuinely degrades, read an existing wallet back as none. The one caller that genuinely degraded,
[`src/shared/phishingDomains.js`](src/shared/phishingDomains.js), takes [`src/shared/phishingDomains.js`](src/shared/phishingDomains.js), took
`storageLocal()` directly and keeps its own null check. `storageLocal()` directly and kept its own null check; it stores nothing at
all as of [#219](https://git.eeqj.de/sneak/AutistMask/issues/219) above.
- 2026-08-17: An address total no longer reports `$0.00` for holdings it cannot - 2026-08-17: An address total no longer reports `$0.00` for holdings it cannot
price. Prices exist for the top 25 tokens only, so the priced-only sum was price. Prices exist for the top 25 tokens only, so the priced-only sum was
printed as the total and an address holding nothing but unpriced ERC-20s was printed as the total and an address holding nothing but unpriced ERC-20s was

View File

@@ -120,25 +120,6 @@ What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no
balances, no identifying information. As with any request, CoinDesk sees your IP balances, no identifying information. As with any request, CoinDesk sees your IP
address. address.
**Phishing domain blocklist** (`raw.githubusercontent.com`)
A community-maintained list of phishing domains, used to warn you when a site
that asks to connect, or to have a transaction or signature approved, is a known
scam. A copy is bundled into the extension at build time, so the protection
works before any network request happens. At runtime the extension fetches the
live list to pick up newly added domains, keeping only the entries not already
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
user-configurable.
When it is contacted: when the background script starts, if the last fetch was
more than 24 hours ago, and every 24 hours after that. The time of the last
fetch is remembered across browser and background restarts, so restarting does
not cause a re-download. If a fetch fails, or the list is too large to keep, the
extension waits an hour before trying again outside that 24-hour schedule rather
than retrying on every restart. It is a plain download of a public file —
nothing about you is sent, but the host sees your IP address. If the fetch
fails, the bundled copy is still used.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia) **Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
When you review a send, AutistMask fetches the recipient's public Etherscan When you review a send, AutistMask fetches the recipient's public Etherscan
@@ -367,8 +348,12 @@ confirmation screen. It contains only addresses involved in fraud -- it is not a
sanctions list. sanctions list.
**Phishing domain warnings.** Sites asking to connect or to have something **Phishing domain warnings.** Sites asking to connect or to have something
approved are checked against the phishing domain blocklist described under approved are checked against a community-maintained list of known phishing
External Services, and flagged with a red banner if they match. domains, and flagged with a red banner if they match. The list is built into the
extension: the check is entirely local, so nobody is told which sites you visit,
and it works offline. It is also only as current as the release you are running
— a domain added to the list upstream reaches you in the next version of the
extension, not the same day.
The first four filters can be individually disabled in Settings if you prefer to The first four filters can be individually disabled in Settings if you prefer to
see everything unfiltered. see everything unfiltered.

View File

@@ -125,6 +125,16 @@ module.exports = [
}, },
}, },
// The helpers the script/ entrypoints call: plain node programs too, run
// from a shell script rather than from yarn, and never bundled.
{
files: ["script/lib/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
// The e2e harnesses are node programs that also carry, inline, the // The e2e harnesses are node programs that also carry, inline, the
// callbacks they ship into the browser via page.evaluate — so both // callbacks they ship into the browser via page.evaluate — so both
// contexts really are present in the same file and both sets of globals // contexts really are present in the same file and both sets of globals

View File

@@ -8,6 +8,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/test-verify-build" "$SCRIPT_DIR/test-verify-build"
"$SCRIPT_DIR/check-censored"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
} }

301
script/check-censored Executable file
View File

@@ -0,0 +1,301 @@
#!/bin/sh
# script/check-censored: assert that the competitor name RULES.md bars appears
# nowhere in this repo, and nowhere in the built extension, except where it is
# deliberate. Our own extension to scripts-to-rule-them-all, run from
# script/check and from make build.
#
# Where the name is allowed, and why each one is not negotiable away:
#
# - script/vendor-blocklist. Build-time tooling, never shipped. A pinned
# source reference that does not say what the source is cannot be verified
# by anyone, so it names it. Whole-file exemption.
# - the two provider-shim identifiers in src/content/inpage.js. Protocol
# identifiers dApps feature-detect on; renaming them does not rename them in
# their code, it only stops this wallet working on their sites.
# - the on-chain name of the MUSD ERC-20 in src/shared/tokenList.js. It is not
# what backs symbol-spoof detection — that reads symbol and address — but
# the wallet already surfaces the on-chain name of any token the user holds
# (src/shared/balances.js), and this contract's on-chain name is that
# string, so censoring the repo cannot stop the wallet displaying it.
# Dropping the entry instead would cost the user MUSD spoof detection.
#
# Everything else fails, in the working tree and under dist/. The last two are
# literals rather than whole files, so they are enforced by counting, and each
# literal is scoped to the path allowed to carry it: a file may contain the name
# only as many times as it contains the literals permitted *there*, and zero
# times anywhere else. The emitted bundles carry them too, so a plain "the name
# must not appear in dist/" could never have passed.
#
# The name itself is not written in this file. script/vendor-blocklist is the
# one place in this repo that defines it, and this reads it back out of there —
# so the repo-wide grep this check exists to enforce keeps returning exactly the
# files named above, and this file is not one of them.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Absolute path to this script, resolved before anything cd's anywhere: the
# scan half runs in a re-invocation through xargs, so that the paths it works on
# arrive as arguments and cannot be reshaped by field splitting on the way in.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag. Not part of the command-line interface.
SCAN_FLAG="--scan-paths"
VENDOR_SCRIPT="$ROOT/script/vendor-blocklist"
# Set by extract_name / make_literals_file.
NAME=""
ALLOWED_LITERALS_FILE=""
FAILED=0
cleanup() {
[ -z "$ALLOWED_LITERALS_FILE" ] || rm -f "$ALLOWED_LITERALS_FILE"
}
trap cleanup EXIT INT TERM
fail() {
echo "check-censored: FAIL: $*" >&2
exit 1
}
# The name, taken from the single place that defines it. A check scanning for a
# pattern it failed to read would pass against anything, so this refuses to
# continue unless it got something that looks like the definition.
extract_name() {
[ -f "$VENDOR_SCRIPT" ] ||
fail "$VENDOR_SCRIPT is missing, and it is where the name being
checked for is defined. Nothing was scanned."
NAME="$(grep -m1 '^UPSTREAM_ORG=' "$VENDOR_SCRIPT" | cut -d'"' -f2)" ||
fail "could not read UPSTREAM_ORG from $VENDOR_SCRIPT. Nothing was
scanned."
case "$NAME" in
"" | *[!A-Za-z0-9]*)
fail "UPSTREAM_ORG in $VENDOR_SCRIPT did not yield a plain name
(got: '$NAME'). Scanning for that would prove nothing. Nothing was
scanned."
;;
esac
}
make_literals_file() {
ALLOWED_LITERALS_FILE="$(mktemp \
"${TMPDIR:-/tmp}/autistmask-censored.XXXXXX")" ||
fail "could not create a temporary file, so nothing was scanned."
}
# The literals $1 may carry, and nothing else may. Each contains the name
# exactly once, which is what makes counting them sound; each is scoped to its
# path, so a file with no business carrying the name fails even when it spells
# it the way shipped code has to. Scoping is the point: permitting these
# literals in any file is what once let this check pass its own prose.
#
# The emitted paths are listed next to the sources they come from. If the
# bundler moves one, this goes red and the new path gets added deliberately,
# rather than a wildcard over dist/ covering whatever lands there.
allowed_literals_for() {
: >"$ALLOWED_LITERALS_FILE"
case "$1" in
src/content/inpage.js | dist/*/src/content/inpage.js)
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
src/shared/tokenList.js | dist/*/src/background/index.js | \
dist/*/src/popup/index.js)
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
;;
esac
}
# How many times does $1 contain the name (TOTAL), and how many of those are one
# of the allowed literals (ALLOWED)? Same discipline the rest of this repo's
# shell checks apply to grep: exit 0 and 1 are answers about the file, anything
# else means the file was not searched and is not an answer at all.
count_matches() {
_cm_status=0
_cm_out="$(grep -a -o -i -F -e "$NAME" -- "$1")" || _cm_status=$?
case "$_cm_status" in
0) TOTAL="$(printf '%s\n' "$_cm_out" | grep -c .)" ;;
1) TOTAL=0 ;;
*)
fail "grep exited $_cm_status reading $1, so the file was never
searched and nothing was established about it. That is a permissions or I/O
fault, not a clean file. Refusing to report success."
;;
esac
if [ "$TOTAL" -eq 0 ]; then
ALLOWED=0
return 0
fi
# No literal is permitted at this path, so every occurrence is a violation.
# Handled here rather than by grep, which is not required to say anything
# useful about an empty pattern file.
if [ ! -s "$ALLOWED_LITERALS_FILE" ]; then
ALLOWED=0
return 0
fi
_cm_status=0
_cm_out="$(grep -a -o -i -F -f "$ALLOWED_LITERALS_FILE" -- "$1")" ||
_cm_status=$?
case "$_cm_status" in
0) ALLOWED="$(printf '%s\n' "$_cm_out" | grep -c .)" ;;
1) ALLOWED=0 ;;
*)
fail "grep exited $_cm_status matching the allowed literals in $1.
Refusing to report success."
;;
esac
}
# The per-path half, run in a re-invocation of this script so it uses the same
# counting as everything else rather than a second copy of it.
scan_paths() {
for _file in "$@"; do
# dist/ arrives absolute (find) and the worktree relative (git
# ls-files). The allowlist is keyed on repo-relative paths, so both
# forms are reduced to one before anything is decided about them.
_rel="$_file"
case "$_rel" in
"$ROOT"/*) _rel="${_rel#"$ROOT"/}" ;;
esac
case "$_rel" in
script/vendor-blocklist) continue ;;
esac
[ -f "$_file" ] || continue
allowed_literals_for "$_rel"
count_matches "$_file"
[ "$TOTAL" -gt "$ALLOWED" ] || continue
FAILED=$((FAILED + 1))
echo "check-censored: $_rel: $TOTAL occurrence(s) of the name," \
"$ALLOWED of them allowed at this path" >&2
grep -a -n -i -F -e "$NAME" -- "$_file" | cut -c1-140 | head -5 >&2
done
[ "$FAILED" -eq 0 ]
}
# Hand a NUL-delimited listing to the scan half. Returns non-zero if any path
# failed, or if the scan could not be run at all.
scan_listing() {
xargs -0 "$SELF" "$SCAN_FLAG" <"$1"
}
# Every file git tracks, plus everything untracked and not ignored: the working
# tree as a reviewer would see it, and never node_modules or dist/ (both are
# ignored; dist/ is walked separately below).
check_worktree() {
_list="$(mktemp "${TMPDIR:-/tmp}/autistmask-censored-tree.XXXXXX")" ||
fail "could not create a temporary file, so nothing was scanned."
_status=0
git ls-files -z --cached --others --exclude-standard >"$_list" ||
_status=$?
[ "$_status" -eq 0 ] || {
rm -f "$_list"
fail "git ls-files exited $_status, so the working tree was never
enumerated and nothing was established about it."
}
# Repo-relative paths. The scan half cd's to the repo root before it opens
# anything, so they reach it intact and unjoined.
WORKTREE_COUNT="$(tr -dc '\0' <"$_list" | wc -c | tr -d ' ')"
_status=0
scan_listing "$_list" || _status=$?
rm -f "$_list"
return "$_status"
}
check_dist() {
_list="$(mktemp "${TMPDIR:-/tmp}/autistmask-censored-dist.XXXXXX")" ||
fail "could not create a temporary file, so dist/ was not scanned."
_status=0
find "$ROOT/dist" -type f -print0 >"$_list" || _status=$?
[ "$_status" -eq 0 ] || {
rm -f "$_list"
fail "find exited $_status enumerating dist/, so part of the emitted
tree was never walked and an unchecked file there went unchecked. Refusing
to report success."
}
DIST_COUNT="$(tr -dc '\0' <"$_list" | wc -c | tr -d ' ')"
_status=0
scan_listing "$_list" || _status=$?
rm -f "$_list"
return "$_status"
}
usage() {
echo "usage: script/check-censored [--require-dist]" >&2
exit 2
}
main() {
cd "$ROOT"
# Internal re-entry from scan_listing's xargs.
if [ "${1-}" = "$SCAN_FLAG" ]; then
shift
extract_name
make_literals_file
scan_paths "$@"
return $?
fi
require_dist=no
case "${1-}" in
"") ;;
--require-dist) require_dist=yes ;;
*) usage ;;
esac
extract_name
make_literals_file
echo "Checking for censored names..."
tree_status=0
check_worktree || tree_status=$?
dist_status=0
dist_inspected=no
DIST_COUNT=0
if [ -d "$ROOT/dist" ]; then
dist_inspected=yes
check_dist || dist_status=$?
fi
if [ "$tree_status" -ne 0 ] || [ "$dist_status" -ne 0 ]; then
fail "the name appears outside the deliberate exceptions (reported
above). See the header of script/check-censored for what is allowed and
why."
fi
if [ "$dist_inspected" = no ]; then
if [ "$require_dist" = yes ]; then
fail "there is no dist/ to inspect and this run was asked to
require one. Run make build."
fi
cat <<EOF
################################################################################
## WARNING: dist/ WAS NOT INSPECTED BY THIS RUN AND IS NOT PROVEN CLEAN BY IT.
## There is no dist/ in this tree. The working tree is clean, but a build can
## carry text no source file does — a dependency's, or a bundler's. Every
## make build runs this check again with dist/ required, so a release artifact
## is always covered; this run simply had none to look at.
################################################################################
EOF
fi
echo "check-censored: $WORKTREE_COUNT tracked file(s) inspected," \
"$DIST_COUNT file(s) under dist/"
}
main "$@"

View File

@@ -0,0 +1,147 @@
// The transform half of script/vendor-blocklist: upstream's config.json in,
// src/shared/phishingBlocklist.json out. Build-time repo tooling; nothing here
// is shipped to users.
//
// Usage: node script/lib/build-blocklist.js <source.json> <output.json>
//
// What it does, and why each step is here:
//
// - only the blacklist is carried over. The extension matches a hostname and
// its parent domains against that one list; upstream's whitelist, fuzzylist
// and version metadata are read by nothing here, so shipping them would add
// megabytes of dead weight to every install.
// - entries are lowercased and de-duplicated, because that is the form
// isPhishingDomain() compares against.
// - entries that cannot be a hostname are dropped and counted. Upstream
// carries the odd URL-shaped entry (a path, a scheme); hostname matching can
// never match one, and once the artifact is hashes nobody can see that it is
// in there, so it is reported at vendoring time instead.
// - entries are hashed (see src/shared/domainHash.js) and sorted, and the
// digests are concatenated into one fixed-width string. Sorted is what makes
// the runtime lookup a binary search over that string, with no set to build
// on every service-worker wake; one string rather than an array of 100k+ is
// what keeps the file, the bundle and the JSON parse small.
//
// Deterministic by construction: same input bytes, same output bytes.
"use strict";
const fs = require("fs");
const {
HASH_ALGORITHM,
HASH_HEX_CHARS,
hashDomain,
} = require("../../src/shared/domainHash");
// A blocklist that has collapsed to a handful of entries is a broken fetch or a
// changed upstream shape, not a quiet day in phishing. Vendoring it would
// disarm the feature, so it fails instead and a human decides.
const MIN_ENTRIES = 10000;
function fail(message) {
process.stderr.write("build-blocklist: " + message + "\n");
process.exit(1);
}
// A hostname, as the matcher understands one: dot-separated labels of letters,
// digits, hyphens and underscores. Anything else — a path, a scheme, a space,
// an empty string, a non-ASCII label a browser would have punycoded before it
// ever reached isPhishingDomain() — cannot be produced by the hostname variants
// the extension looks up, so it could only ever sit in the artifact unused.
//
// Underscores are deliberate. They are not legal in a hostname per RFC 1123,
// but DNS carries them and browsers resolve them, and upstream lists 141 entries
// that use one — real phishing sites on shared subdomain hosts. A stricter
// pattern silently drops every one of them.
const HOSTNAME_RE =
/^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?)+$/;
function main(argv) {
const [source, output] = argv;
if (!source || !output) {
fail("usage: build-blocklist.js <source.json> <output.json>");
}
let config;
try {
config = JSON.parse(fs.readFileSync(source, "utf8"));
} catch (e) {
fail("could not read " + source + " as JSON: " + e.message);
}
if (!Array.isArray(config.blacklist)) {
fail(
"the source has no blacklist array, so its shape is not the one " +
"this transform understands. Refusing to write an artifact.",
);
}
const seen = new Set();
let dropped = 0;
for (const raw of config.blacklist) {
if (typeof raw !== "string") {
dropped++;
continue;
}
const domain = raw.trim().toLowerCase();
if (!HOSTNAME_RE.test(domain)) {
dropped++;
continue;
}
seen.add(domain);
}
if (seen.size < MIN_ENTRIES) {
fail(
"the source yielded " +
seen.size +
" usable entries, below the " +
MIN_ENTRIES +
" floor. That is a broken source or a changed upstream " +
"shape, and vendoring it would disarm phishing detection. " +
"Refusing to write an artifact.",
);
}
const hashes = [];
for (const domain of seen) hashes.push(hashDomain(domain));
hashes.sort();
// Truncation makes collisions possible; they are harmless (both entries are
// blocked either way) but they must not inflate the count the artifact
// claims, which the runtime cross-checks against the string length.
const unique = [];
for (const hash of hashes) {
if (unique.length === 0 || unique[unique.length - 1] !== hash) {
unique.push(hash);
}
}
const artifact = {
algorithm: HASH_ALGORITHM,
hashHexChars: HASH_HEX_CHARS,
count: unique.length,
hashes: unique.join(""),
};
// Four-space JSON with a trailing newline: what prettier emits for this
// shape, so a vendored artifact passes make fmt-check untouched.
fs.writeFileSync(output, JSON.stringify(artifact, null, 4) + "\n");
process.stdout.write(
"build-blocklist: " +
config.blacklist.length +
" source entries -> " +
seen.size +
" usable domains -> " +
unique.length +
" digests (" +
dropped +
" not hostnames, " +
(seen.size - unique.length) +
" digest collisions)\n",
);
}
main(process.argv.slice(2));

View File

@@ -59,12 +59,12 @@ main() {
# browser profile. # browser profile.
# PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it, # PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it,
# ctx.route() intercepts page requests only, and every fetch made by # ctx.route() intercepts page requests only, and every fetch made by
# the MV3 background service worker — including the phishing # the MV3 background service worker — the JSON-RPC calls behind
# blocklist fetch that src/background/index.js issues at worker # every approval the suite drives among them — goes to the real
# startup — goes to the real internet. The flag is experimental and # internet. The flag is experimental and Playwright may drop or
# Playwright may drop or rename it. It cannot break silently: the # rename it. It cannot break silently: the harness asks the worker
# harness probes service-worker interception at launch and aborts # for one request of its own at launch and aborts the whole suite
# the whole suite if it is not in effect (see the interception # if it does not reach the route handler (see the interception
# canary in tests/e2e/harness.js). If a future Playwright removes # canary in tests/e2e/harness.js). If a future Playwright removes
# the flag, that probe is what will fail, and the fix is either a # the flag, that probe is what will fail, and the fix is either a
# replacement mechanism or an honest downgrade of the isolation # replacement mechanism or an honest downgrade of the isolation

105
script/vendor-blocklist Executable file
View File

@@ -0,0 +1,105 @@
#!/bin/sh
# script/vendor-blocklist: refresh the vendored phishing blocklist at
# src/shared/phishingBlocklist.json from its upstream source. Our own extension
# to scripts-to-rule-them-all.
#
# This is build-time repo tooling and is not shipped. It is the one place in
# this repo that names the upstream project, because a source reference that
# does not say what the source is cannot be verified by anyone; the artifact it
# writes carries no names at all (see src/shared/domainHash.js).
# script/check-censored reads the name back out of this file rather than
# repeating it, so it stays defined exactly once.
#
# Run it deliberately, not on every build: the output is committed, and the
# extension does no runtime fetching, so the shipped list is exactly as fresh as
# the last time someone ran this and landed the result. Re-run it, land the
# diff, cut a release; that is the whole refresh path.
#
# Pinned by content hash, twice over, as REPO_POLICIES.md requires. The commit
# below is an immutable ref — the upstream default branch moves several times a
# day and cannot be pinned — and UPSTREAM_SHA256 is the sha256 of the bytes that
# commit serves. A mismatch is a hard failure: a vendoring step that accepts
# whatever it is handed is a supply-chain hole, and this one feeds a security
# warning shown to users.
#
# To move the pin: pick the new commit, run this with the new UPSTREAM_COMMIT
# and an UPSTREAM_SHA256 you have not yet updated, and it will print the hash it
# actually got. Verify that hash against the source independently before
# recording it. Never copy the "actual" line in on trust.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Upstream, pinned 2026-08-17.
UPSTREAM_ORG="MetaMask"
UPSTREAM_REPO="eth-phishing-detect"
UPSTREAM_COMMIT="6dddf74a87da3e1a0841f7ae0d1cb31aaf2c05db"
UPSTREAM_FILE="src/config.json"
UPSTREAM_SHA256="166d5b3504e8f4ed52eae37d3dd20c1a56efa0502bfb3dc957044ff8b5f1283f"
OUTPUT="src/shared/phishingBlocklist.json"
WORK=""
cleanup() {
[ -z "$WORK" ] || rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
fail() {
echo "vendor-blocklist: $*" >&2
exit 1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | cut -d' ' -f1
else
fail "neither sha256sum nor shasum is available, so the fetched
source cannot be verified. Refusing to vendor unverified content."
fi
}
main() {
cd "$ROOT"
command -v curl >/dev/null 2>&1 ||
fail "curl is required to fetch the upstream list"
command -v node >/dev/null 2>&1 ||
fail "node is required to build the artifact; run script/bootstrap"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-vendor-blocklist.XXXXXX")" ||
fail "could not create a working directory"
url="https://raw.githubusercontent.com/$UPSTREAM_ORG/$UPSTREAM_REPO/$UPSTREAM_COMMIT/$UPSTREAM_FILE"
echo "Fetching $url"
curl -fsSL --proto '=https' --tlsv1.2 -o "$WORK/source.json" "$url" ||
fail "the fetch failed, so nothing was vendored"
actual="$(sha256_of "$WORK/source.json")"
if [ "$actual" != "$UPSTREAM_SHA256" ]; then
fail "sha256 mismatch on the fetched source.
expected: $UPSTREAM_SHA256
actual: $actual
The pinned commit is immutable, so the same commit serving different bytes
means the content was substituted somewhere between upstream and here.
Nothing was written. Do not update the expectation to match unless you have
verified the new bytes independently."
fi
echo "Verified sha256 $actual"
node script/lib/build-blocklist.js "$WORK/source.json" "$WORK/out.json" ||
fail "the transform failed, so nothing was written"
if [ -f "$OUTPUT" ] && cmp -s "$WORK/out.json" "$OUTPUT"; then
echo "vendor-blocklist: $OUTPUT is already up to date"
return 0
fi
cp "$WORK/out.json" "$OUTPUT"
echo "vendor-blocklist: wrote $OUTPUT (sha256 $(sha256_of "$OUTPUT"))"
}
main "$@"

View File

@@ -27,14 +27,9 @@ const {
TX_STAGE_NONCE, TX_STAGE_NONCE,
} = require("../shared/approvalVerify"); } = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx"); const { prepareApprovalTx } = require("../shared/approvalTx");
const { const { isPhishingDomain } = require("../shared/phishingDomains");
isPhishingDomain,
refreshPhishingListOnSchedule,
initPhishingList,
} = require("../shared/phishingDomains");
const { const {
BALANCE_REFRESH_ALARM, BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES, BALANCE_REFRESH_PERIOD_MINUTES,
ensureRecurringAlarms, ensureRecurringAlarms,
registerAlarmHandlers, registerAlarmHandlers,
@@ -677,6 +672,34 @@ async function handleRpc(method, params, origin) {
} }
if (method === "wallet_switchEthereumChain") { if (method === "wallet_switchEthereumChain") {
// Gated exactly like the signing methods, and gated before the
// same-chain early return. Switching the chain is wallet-wide: it
// moves the network the popup shows and the endpoints every other
// tab is served from, so a page the user never connected to must
// not be able to do it. Ungated, any page could clear the
// [TESTNET] banner under a user who believed they were on Sepolia.
const s = await getState();
const activeAddress = await getActiveAddress();
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
// onChainSwitch() mutates the module-level state singleton and then
// saves every field of it, and currentNetwork() reads the same
// singleton. This worker may have been started by this very message:
// nothing loads state at module scope, so without this the singleton
// is DEFAULT_STATE, the same-chain check compares against the wrong
// network, and the save writes empty wallets, empty allowedSites and
// the default endpoints over the user's stored profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). Same precedent
// as the transaction path below.
await loadState();
const chainId = params?.[0]?.chainId; const chainId = params?.[0]?.chainId;
if (chainId === currentNetwork().chainId) { if (chainId === currentNetwork().chainId) {
return { result: null }; return { result: null };
@@ -1052,26 +1075,20 @@ async function backgroundRefresh() {
await saveState(); await saveState();
} }
// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is // The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
// a service worker that the browser terminates after about 30 seconds idle, // a service worker that the browser terminates after about 30 seconds idle,
// so a setInterval would only ever survive until the first idle period and // so a setInterval would only ever survive until the first idle period and
// module-level state does not outlive it. Alarms are held by the browser and // module-level state does not outlive it. Alarms are held by the browser and
// wake the worker to deliver them. // wake the worker to deliver them.
registerAlarmHandlers({ registerAlarmHandlers({
[BALANCE_REFRESH_ALARM]: backgroundRefresh, [BALANCE_REFRESH_ALARM]: backgroundRefresh,
// The scheduled refresh, which restores persisted state on a freshly
// revived worker and then fetches unconditionally. The freshness guards
// belong to the startup path; applying them here would make the tick skip
// itself.
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
}); });
// Everything the background context needs re-established on start. This runs // Everything the background context needs re-established on start. This runs
// on a fresh install, on browser startup, and on every revival of a // on a fresh install, on browser startup, and on every revival of a
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only // terminated worker, so it must be idempotent: ensureRecurringAlarms() only
// creates alarms that are missing or carrying a stale period, and // creates alarms that are missing or carrying a stale period, and only clears
// initPhishingList() fetches only when the persisted timestamps say the list // retired ones that are still registered.
// is stale.
// //
// On a fresh install the top-level call and the onInstalled listener both run, // On a fresh install the top-level call and the onInstalled listener both run,
// close enough together that both could see an alarm missing and create it. // close enough together that both could see an alarm missing and create it.
@@ -1082,10 +1099,7 @@ let backgroundJobsRun = null;
function startBackgroundJobs() { function startBackgroundJobs() {
if (backgroundJobsRun) return backgroundJobsRun; if (backgroundJobsRun) return backgroundJobsRun;
backgroundJobsRun = Promise.all([ backgroundJobsRun = ensureRecurringAlarms()
ensureRecurringAlarms(),
initPhishingList(),
])
.catch((err) => { .catch((err) => {
// An alarm that failed to schedule means a recurring job silently // An alarm that failed to schedule means a recurring job silently
// never runs again; it must not be an unhandled rejection. // never runs again; it must not be an unhandled rejection.

View File

@@ -179,7 +179,8 @@
return this; return this;
}, },
// Some dApps (wagmi) check this to confirm MetaMask-like behavior // Some dApps (wagmi) probe this object to decide whether the provider
// supports the de-facto standard extras. The name is theirs, not ours.
_metamask: { _metamask: {
isUnlocked() { isUnlocked() {
return Promise.resolve(provider.selectedAddress !== null); return Promise.resolve(provider.selectedAddress !== null);

View File

@@ -25,6 +25,10 @@ const {
getFullWarnings, getFullWarnings,
} = require("../../shared/addressWarnings"); } = require("../../shared/addressWarnings");
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants"); const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const {
displayedDecimals,
transferAmountUnits,
} = require("../../shared/transferAmount");
const { const {
CODES, CODES,
FEE_PENDING, FEE_PENDING,
@@ -302,8 +306,17 @@ async function estimateGas(txInfo) {
}); });
} else { } else {
const contract = new Contract(txInfo.token, ERC20_ABI, provider); const contract = new Contract(txInfo.token, ERC20_ABI, provider);
const decimals = await contract.decimals(); // The scale the screen is rendering with, not the contract's own
const amount = parseUnits(txInfo.amount, decimals); // answer: the estimate has to be for the transfer that would be
// signed, and that one is encoded from what was displayed. See
// transferAmount.js. A pending transaction that carries no usable
// scale throws here, which reports the fee as unknown and leaves
// Send blocked — an amount that cannot be checked against the
// screen is never estimated for, let alone sent.
const amount = parseUnits(
txInfo.amount,
displayedDecimals(txInfo.tokenDecimals),
);
gasLimit = await contract.transfer.estimateGas(txInfo.to, amount, { gasLimit = await contract.transfer.estimateGas(txInfo.to, amount, {
from: txInfo.from, from: txInfo.from,
}); });
@@ -445,8 +458,16 @@ function init(_ctx) {
ERC20_ABI, ERC20_ABI,
connectedSigner, connectedSigner,
); );
const decimals = await contract.decimals(); // The contract's decimals() is read to be COMPARED with the
const amount = parseUnits(pendingTx.amount, decimals); // scale the screen rendered this amount at, not to encode with:
// encoding from it signs whatever the contract answers now,
// which is not what the user read. A disagreement throws and is
// reported on the error screen. See transferAmount.js.
const amount = transferAmountUnits(
pendingTx.amount,
pendingTx.tokenDecimals,
await contract.decimals(),
);
tx = await contract.transfer(pendingTx.to, amount); tx = await contract.transfer(pendingTx.to, amount);
} }

View File

@@ -220,6 +220,11 @@ function init(_ctx) {
let tokenSymbol = null; let tokenSymbol = null;
let tokenBalance = null; let tokenBalance = null;
// The scale the amount and the balance below are rendered at, carried
// forward so the transfer is encoded with the number the user read
// rather than with whatever the contract answers at signing time. See
// src/shared/transferAmount.js.
let tokenDecimals = null;
if (token !== "ETH") { if (token !== "ETH") {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
@@ -230,6 +235,7 @@ function init(_ctx) {
state.trackedTokens, state.trackedTokens,
); );
tokenBalance = tb ? tb.balance || "0" : "0"; tokenBalance = tb ? tb.balance || "0" : "0";
tokenDecimals = tb ? tb.decimals : null;
} }
ctx.showConfirmTx({ ctx.showConfirmTx({
@@ -241,6 +247,7 @@ function init(_ctx) {
balance: addr.balance, balance: addr.balance,
tokenSymbol: tokenSymbol, tokenSymbol: tokenSymbol,
tokenBalance: tokenBalance, tokenBalance: tokenBalance,
tokenDecimals: tokenDecimals,
}); });
}); });

View File

@@ -17,16 +17,26 @@
// run finished and the alarm fires one run-duration earlier than that. Every // run finished and the alarm fires one run-duration earlier than that. Every
// guard must therefore either be strictly shorter than the period it gates or // guard must therefore either be strictly shorter than the period it gates or
// be bypassed on the scheduled tick — see backgroundRefresh() in // be bypassed on the scheduled tick — see backgroundRefresh() in
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js. // src/background/index.js.
const { alarmsApi } = require("./browserApi"); const { alarmsApi } = require("./browserApi");
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh"; const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
// Alarms this extension used to create and no longer has a handler for. A
// browser keeps an alarm until something clears it, so a job that is deleted
// from the code goes on waking the service worker on its old schedule forever,
// on every install that ever ran the version which created it. Removing the job
// means removing the alarm, so retired names are listed here and cleared on
// every start until the installs that carry them are long gone.
const OBSOLETE_ALARMS = [
// The 24-hour phishing blocklist refresh, retired when the runtime fetch
// was removed and the list became purely build-time vendored.
"autistmask-phishing-refresh",
];
const MIN_ALARM_PERIOD_MINUTES = 1; const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_PERIOD_MINUTES = 1; const BALANCE_REFRESH_PERIOD_MINUTES = 1;
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
// alarmsApi() resolves on use rather than at module load: the worker is torn // alarmsApi() resolves on use rather than at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring // down and re-evaluated repeatedly, and tests install a stub after requiring
@@ -65,22 +75,34 @@ async function ensureAlarm(name, periodInMinutes) {
} }
/** /**
* Ensure both recurring background jobs are scheduled. Safe to call on every * Clear every alarm this extension no longer handles.
* worker start, on onInstalled and on onStartup.
* *
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this * @returns {Promise<string[]>} the retired alarms this call actually cleared.
* call had to create. */
async function clearObsoleteAlarms() {
const api = alarmsApi();
if (!api || !api.clear) return [];
const cleared = [];
for (const name of OBSOLETE_ALARMS) {
if (await api.clear(name)) cleared.push(name);
}
return cleared;
}
/**
* Ensure the recurring background jobs are scheduled, and that retired ones are
* not. Safe to call on every worker start, on onInstalled and on onStartup.
*
* @returns {Promise<{balance: boolean, cleared: string[]}>} which alarms this
* call had to create, and which retired ones it removed.
*/ */
async function ensureRecurringAlarms() { async function ensureRecurringAlarms() {
const balance = await ensureAlarm( const balance = await ensureAlarm(
BALANCE_REFRESH_ALARM, BALANCE_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES, BALANCE_REFRESH_PERIOD_MINUTES,
); );
const phishing = await ensureAlarm( const cleared = await clearObsoleteAlarms();
PHISHING_REFRESH_ALARM, return { balance, cleared };
PHISHING_REFRESH_PERIOD_MINUTES,
);
return { balance, phishing };
} }
/** /**
@@ -102,10 +124,10 @@ function registerAlarmHandlers(handlers) {
module.exports = { module.exports = {
BALANCE_REFRESH_ALARM, BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM, OBSOLETE_ALARMS,
MIN_ALARM_PERIOD_MINUTES, MIN_ALARM_PERIOD_MINUTES,
BALANCE_REFRESH_PERIOD_MINUTES, BALANCE_REFRESH_PERIOD_MINUTES,
PHISHING_REFRESH_PERIOD_MINUTES, clearObsoleteAlarms,
ensureAlarm, ensureAlarm,
ensureRecurringAlarms, ensureRecurringAlarms,
registerAlarmHandlers, registerAlarmHandlers,

View File

@@ -19,9 +19,26 @@ async function onChainSwitch(newNetworkId) {
const net = networkById(newNetworkId); const net = networkById(newNetworkId);
// --- core identity --- // --- core identity ---
// Endpoints are remembered per network rather than reset to the
// defaults, because a user who points the wallet at their own node has
// no way to get that URL back once it is gone: overwriting it moved
// every address and every transaction onto a third-party endpoint
// silently and permanently.
//
// state.rpcUrl / state.blockscoutUrl stay the live endpoints of the
// active network, so nothing that reads them changes. The invariant is
// that for the ACTIVE network those two fields are authoritative and
// the map entry may be stale (Settings writes the fields directly);
// for every other network the map is authoritative. Snapshotting the
// outgoing network here, before the switch, is what reconciles them.
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
const remembered = state.networkEndpoints[net.id] || {};
state.networkId = net.id; state.networkId = net.id;
state.rpcUrl = net.defaultRpcUrl; state.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
state.blockscoutUrl = net.defaultBlockscoutUrl; state.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
// --- price cache --- // --- price cache ---
// Prices are chain-specific (testnet tokens are worthless, // Prices are chain-specific (testnet tokens are worthless,

41
src/shared/domainHash.js Normal file
View File

@@ -0,0 +1,41 @@
// The one definition of how a domain becomes a blocklist entry.
//
// The vendored phishing blocklist ships digests, not domain names: see
// phishingDomains.js for why, and script/vendor-blocklist for how the artifact
// is produced. Both sides have to agree exactly — a mismatch would silently
// match nothing, which is a blocklist that quietly protects no one — so the
// rule lives here and is required by both rather than written down twice.
//
// sha256 truncated to 64 bits. Truncation is what keeps the artifact small
// enough to bundle (16 hex characters per entry rather than 64), and 64 bits is
// far past what this has to withstand: over ~10^5 entries the chance that any
// hostname a user visits collides with an entry it is not is about 10^-14 per
// lookup, and a deliberate collision buys an attacker a false phishing warning
// on a site they do not control, not a missed one. For scale, Safe Browsing
// distributes 32-bit prefixes and resolves the rest against a server; this is
// 32 bits more, with no server involved.
const { sha256, toUtf8Bytes } = require("ethers");
const HASH_ALGORITHM = "sha256";
const HASH_HEX_CHARS = 16;
/**
* The blocklist entry for a domain: lowercased, hashed, truncated.
*
* @param {string} domain
* @returns {string} HASH_HEX_CHARS lowercase hex characters, no 0x prefix.
*/
function hashDomain(domain) {
// ethers returns "0x" + 64 hex characters.
return sha256(toUtf8Bytes(domain.toLowerCase())).slice(
2,
2 + HASH_HEX_CHARS,
);
}
module.exports = {
HASH_ALGORITHM,
HASH_HEX_CHARS,
hashDomain,
};

View File

@@ -4,8 +4,7 @@
// //
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker, // POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
// so this module must not be pulled into src/background/. Anything the // so this module must not be pulled into src/background/. Anything the
// background context needs to cache goes in extension storage instead (see // background context needs to cache goes in extension storage instead.
// shared/phishingDomains.js).
const { getProvider } = require("./balances"); const { getProvider } = require("./balances");
const { log } = require("./log"); const { log } = require("./log");

File diff suppressed because one or more lines are too long

View File

@@ -1,158 +1,109 @@
// Domain-based phishing detection using a vendored blocklist with delta updates. // Domain-based phishing detection against a blocklist vendored at build time.
// //
// A community-maintained phishing domain blocklist is vendored in // The list is produced by script/vendor-blocklist from a hash-pinned upstream
// phishingBlocklist.json and bundled at build time. At runtime, we fetch // commit, committed as phishingBlocklist.json, and bundled. There is no runtime
// the live list periodically and keep only the delta (new entries not in // fetch: the extension asks nobody anything to answer this question, so no third
// the vendored list) in memory. This keeps runtime memory usage small. // party learns which sites a user connects to, and no third party decides what
// this wallet warns about. The cost is staleness — the shipped list is exactly
// as fresh as the last vendoring run that was released — and the refresh path is
// re-running that script and shipping the diff.
// //
// The domain-checker checks the in-memory delta first (fresh/recent scam // The artifact holds digests, not domains: sha256 truncated to 64 bits, one
// sites), then falls back to the vendored list. // entry per 16 hex characters, concatenated in sorted order into a single
// string (see domainHash.js). Three things follow from that shape, and all
// three are the reason for it:
// //
// If the delta and its fetch timestamp fit in 256 KiB they are persisted to // - the extension ships no plaintext list of anyone's domain names, which is
// extension storage, so they survive termination of the MV3 service worker. // what makes a blocklist assembled elsewhere shippable here at all.
// Extension storage, not localStorage: localStorage does not exist in a // - a lookup is a binary search over that string. Nothing is built at module
// service worker, so the previous persistence never ran on Chrome at all. // load, which matters because the MV3 service worker is torn down when idle
// The stored timestamps are what keep a restarted worker from re-fetching on // and re-evaluates this file on every wake.
// every wake while still noticing an overdue update. Those guards apply to the // - the file is 1.7 MB rather than 8.7 MB.
// startup path only; the 24-hour alarm tick bypasses them, or it would veto //
// its own refresh — see updatePhishingList(). // Nothing here is async: callers answer an approval prompt with the result.
const vendoredConfig = require("./phishingBlocklist.json"); const vendored = require("./phishingBlocklist.json");
const { storageLocal } = require("./browserApi"); const { HASH_ALGORITHM, HASH_HEX_CHARS, hashDomain } = require("./domainHash");
const BLOCKLIST_URL = // The artifact is generated, so a shape it does not have is a build fault, not
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json"; // a runtime condition. It is checked anyway, and loudly, because every way of
// getting it wrong — a stale format, a truncated file, a different digest —
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours // produces a blocklist that matches nothing at all while looking perfectly
// healthy. A phishing check that silently answers "no" to everything is the one
// Floor on how often an unscheduled path may hit the network. The worker is // failure this module must not have.
// revived every ~30 seconds while the browser is busy, and every revival runs function checkArtifact(a) {
// the startup path; without a persisted record of the last attempt, any state const bad = (why) =>
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large new Error(
// to store — would download the full list on every single wake. "phishingBlocklist.json " +
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour why +
". It is generated by script/vendor-blocklist; re-run that " +
const DELTA_STORAGE_KEY = "phishing-delta"; "rather than editing it.",
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
// Vendored set — built once from the bundled JSON.
const vendoredBlacklist = new Set(
(vendoredConfig.blacklist || []).map((d) => d.toLowerCase()),
); );
// Delta set — only entries from live list that are NOT in vendored. if (!a || typeof a !== "object") throw bad("is not an object");
let deltaBlacklist = new Set(); if (a.algorithm !== HASH_ALGORITHM) {
let lastFetchTime = 0; throw bad(
let lastAttemptTime = 0; "declares algorithm " +
let fetchPromise = null; JSON.stringify(a.algorithm) +
let loadPromise = null; ", but this build hashes with " +
HASH_ALGORITHM,
// storageLocal() resolves on use rather than at module load, so a test can
// install a stub after requiring this module, and it returns null where the
// API is absent — which is why the popup, with no reason to touch the delta,
// loads fine without it.
/**
* Sanitise a timestamp read back from storage.
*
* A value in the future is permanent poison: every guard here measures elapsed
* time as `Date.now() - stamp` and tests only the lower bound, so a stamp a
* year ahead suppresses updates for a year with no path that ever clears it.
* Clock skew and a restored profile backup both produce one. Since these
* timestamps only ever gate work, discarding an impossible one is safe: it
* costs at most a single extra fetch and restores a sane value immediately.
*
* @param {unknown} value
* @returns {number} the timestamp, or 0 if it is unusable.
*/
function sanitizeTimestamp(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
if (value <= 0 || value > Date.now()) return 0;
return value;
}
/**
* Load the persisted delta and its timestamps from extension storage.
* Runs once per worker lifetime; every entry point funnels through
* ensureDeltaLoaded() so a wake from termination restores state exactly once.
*
* @returns {Promise<void>}
*/
async function loadDeltaFromStorage() {
const storage = storageLocal();
if (!storage) return;
try {
const result = await storage.get(DELTA_STORAGE_KEY);
const data = result && result[DELTA_STORAGE_KEY];
if (!data) return;
if (Array.isArray(data.blacklist)) {
deltaBlacklist = new Set(
data.blacklist.map((d) => d.toLowerCase()),
); );
} }
lastFetchTime = sanitizeTimestamp(data.lastFetchTime); if (a.hashHexChars !== HASH_HEX_CHARS) {
lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime); throw bad(
} catch { "declares " +
// Storage unavailable or corrupt — start empty and re-fetch. JSON.stringify(a.hashHexChars) +
} " hex characters per entry, but this build produces " +
} HASH_HEX_CHARS,
function ensureDeltaLoaded() {
if (!loadPromise) loadPromise = loadDeltaFromStorage();
return loadPromise;
}
/**
* Persist the delta and its timestamps if they fit within MAX_DELTA_BYTES.
*
* The 256 KiB cap covers the delta and its freshness claim: when the delta is
* too large to keep, lastFetchTime goes with it, so the next start re-fetches
* rather than trusting a freshness claim for a delta it no longer holds.
* lastAttemptTime is written either way — it records that the network was
* contacted, which stays true whatever became of the response, and it is what
* stops a permanently oversized list from downloading on every worker wake.
*
* @returns {Promise<void>}
*/
async function saveDeltaToStorage() {
const storage = storageLocal();
if (!storage) return;
try {
const data = {
blacklist: Array.from(deltaBlacklist),
lastFetchTime,
lastAttemptTime,
};
const json = JSON.stringify(data);
if (json.length < MAX_DELTA_BYTES) {
await storage.set({ [DELTA_STORAGE_KEY]: data });
} else if (lastAttemptTime > 0) {
await storage.set({ [DELTA_STORAGE_KEY]: { lastAttemptTime } });
} else {
await storage.remove(DELTA_STORAGE_KEY);
}
} catch {
// Storage unavailable — skip silently
}
}
/**
* Load a pre-parsed config and compute the delta against the vendored list.
* Used for both live fetches and testing.
*
* @param {{ blacklist?: string[] }} config
* @returns {Promise<void>} resolves once the delta has been persisted.
*/
function loadConfig(config) {
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
// Delta = entries in the live list that are NOT in the vendored list
deltaBlacklist = new Set(
liveBlacklist.filter((d) => !vendoredBlacklist.has(d)),
); );
}
if (typeof a.hashes !== "string") throw bad("has no hashes string");
if (!Number.isInteger(a.count) || a.count < 1) {
throw bad("declares no usable entry count");
}
if (a.hashes.length !== a.count * HASH_HEX_CHARS) {
throw bad(
"holds " +
a.hashes.length +
" hex characters, which is not the " +
a.count * HASH_HEX_CHARS +
" its count of " +
a.count +
" entries requires",
);
}
}
lastFetchTime = Date.now(); checkArtifact(vendored);
return saveDeltaToStorage();
const HASHES = vendored.hashes;
const COUNT = vendored.count;
/**
* Is this digest one of the vendored entries?
*
* Binary search over fixed-width records. The digests are lowercase hex of one
* width, so lexicographic order is numeric order and the artifact is written
* sorted; tests assert that ordering against the committed file, because an
* unsorted artifact would fail lookups silently rather than loudly.
*
* @param {string} hash
* @returns {boolean}
*/
function hashListed(hash) {
let lo = 0;
let hi = COUNT - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const at = HASHES.slice(
mid * HASH_HEX_CHARS,
(mid + 1) * HASH_HEX_CHARS,
);
if (at === hash) return true;
if (at < hash) lo = mid + 1;
else hi = mid - 1;
}
return false;
} }
/** /**
@@ -175,161 +126,33 @@ function hostnameVariants(hostname) {
/** /**
* Check if a hostname is on the phishing blocklist. * Check if a hostname is on the phishing blocklist.
* Checks delta first (fresh/recent scam sites), then vendored list.
*
* Synchronous by design — callers answer an approval prompt with it. On a
* worker that has just woken, the persisted delta may still be loading; the
* vendored list, which is bundled and always present, carries the check until
* it lands.
* *
* @param {string} hostname - The hostname to check. * @param {string} hostname - The hostname to check.
* @returns {boolean} * @returns {boolean}
*/ */
function isPhishingDomain(hostname) { function isPhishingDomain(hostname) {
if (!hostname) return false; if (!hostname) return false;
const variants = hostnameVariants(hostname); for (const variant of hostnameVariants(hostname)) {
if (hashListed(hashDomain(variant))) return true;
// Check delta blacklist first (fresh/recent scam sites), then vendored
for (const v of variants) {
if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true;
} }
return false; return false;
} }
/** /**
* Fetch the latest blocklist and compute delta against vendored data. * Return the blocklist size for diagnostics.
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS,
* counted from the persisted timestamp so the cache outlives the worker.
*
* `force` is what makes the 24-hour alarm actually refresh every 24 hours.
* The alarm fires one period after the previous alarm, but lastFetchTime is
* stamped when that fetch *completed*, so an unforced tick lands one fetch
* latency inside its own TTL, skips, and turns the real cadence into 48 hours.
* Shortening the TTL instead would not fix it: the worker wakes every ~30
* seconds and the startup path re-checks the TTL each time, so a shortened TTL
* simply becomes the real cadence. The TTL is there to stop redundant fetches
* on wake, and the scheduled tick is not redundant, so it bypasses it.
*
* @param {{force?: boolean}} [opts] force: fetch unless one is already in
* flight, ignoring both the freshness and the retry guard. For the scheduled
* alarm tick only.
* @returns {Promise<void>}
*/
async function updatePhishingList({ force = false } = {}) {
// A worker that has just been revived knows nothing until the persisted
// record is back in memory; without this the freshness check below would
// always see 0 and re-fetch on every wake.
await ensureDeltaLoaded();
if (!force) {
const now = Date.now();
// Skip if recently fetched.
if (lastFetchTime > 0 && now - lastFetchTime < CACHE_TTL_MS) return;
// Skip if the network was contacted recently and the result was not
// usable — a failed fetch or an oversized delta leaves lastFetchTime
// unset, and without this every wake would retry.
if (
lastAttemptTime > 0 &&
now - lastAttemptTime < MIN_FETCH_ATTEMPT_INTERVAL_MS
) {
return;
}
}
// De-duplicate concurrent calls
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
lastAttemptTime = Date.now();
try {
const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status);
const config = await resp.json();
await loadConfig(config);
} catch {
// Silently fail — vendored list still provides coverage. Persist
// the attempt so a persistently failing fetch is retried on the
// schedule rather than on every wake.
await saveDeltaToStorage();
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
/**
* Restore persisted state and fetch if the list is overdue.
*
* Called from the background script every time it starts — a fresh install,
* a browser start, and every revival of a terminated service worker all land
* here. The recurring 24-hour schedule itself is an alarm (see
* shared/alarms.js), not a timer, because timers die with the worker.
*
* @returns {Promise<void>}
*/
async function initPhishingList() {
await ensureDeltaLoaded();
return updatePhishingList();
}
/**
* The 24-hour alarm tick. Separate from initPhishingList() because this is the
* scheduled refresh and must not be vetoed by the guards that exist to keep
* the unscheduled startup path off the network.
*
* @returns {Promise<void>}
*/
async function refreshPhishingListOnSchedule() {
return updatePhishingList({ force: true });
}
/**
* Return the total blocklist size (vendored + delta) for diagnostics.
* *
* @returns {number} * @returns {number}
*/ */
function getBlocklistSize() { function getBlocklistSize() {
return vendoredBlacklist.size + deltaBlacklist.size; return COUNT;
}
/**
* Return the delta blocklist size for diagnostics.
*
* @returns {number}
*/
function getDeltaSize() {
return deltaBlacklist.size;
}
/**
* Reset internal state (for testing).
*/
function _reset() {
deltaBlacklist = new Set();
lastFetchTime = 0;
lastAttemptTime = 0;
fetchPromise = null;
loadPromise = null;
} }
module.exports = { module.exports = {
isPhishingDomain, isPhishingDomain,
updatePhishingList,
refreshPhishingListOnSchedule,
initPhishingList,
loadDeltaFromStorage,
loadConfig,
CACHE_TTL_MS,
MIN_FETCH_ATTEMPT_INTERVAL_MS,
DELTA_STORAGE_KEY,
MAX_DELTA_BYTES,
getBlocklistSize, getBlocklistSize,
getDeltaSize,
hostnameVariants, hostnameVariants,
_reset, // Exposed for testing only: the ends of the search range are where an
// Exposed for testing only // off-by-one hides, and reaching them through isPhishingDomain() would mean
_getVendoredBlacklistSize: () => vendoredBlacklist.size, // knowing which domain hashes to the first or last entry.
_getDeltaBlacklist: () => deltaBlacklist, _hashListed: hashListed,
}; };

View File

@@ -14,6 +14,11 @@ const DEFAULT_STATE = {
networkId: "mainnet", networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL, rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL, blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See onChainSwitch().
networkEndpoints: {},
lastBalanceRefresh: 0, lastBalanceRefresh: 0,
activeAddress: null, activeAddress: null,
allowedSites: {}, allowedSites: {},
@@ -34,6 +39,9 @@ const DEFAULT_STATE = {
const state = { const state = {
...DEFAULT_STATE, ...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
// mutates this map in place, and a spread copies the reference.
networkEndpoints: {},
currentView: null, currentView: null,
selectedWallet: null, selectedWallet: null,
selectedAddress: null, selectedAddress: null,
@@ -88,6 +96,7 @@ async function saveState() {
networkId: state.networkId, networkId: state.networkId,
rpcUrl: state.rpcUrl, rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl, blockscoutUrl: state.blockscoutUrl,
networkEndpoints: state.networkEndpoints,
lastBalanceRefresh: state.lastBalanceRefresh, lastBalanceRefresh: state.lastBalanceRefresh,
activeAddress: state.activeAddress, activeAddress: state.activeAddress,
allowedSites: state.allowedSites, allowedSites: state.allowedSites,
@@ -128,6 +137,30 @@ async function loadState() {
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.blockscoutUrl = state.blockscoutUrl =
saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl; saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the
// code below and onChainSwitch() index and ASSIGN INTO this value,
// and assigning a property to a string or a number is a silent no-op
// in sloppy mode. A stored primitive would therefore be re-persisted
// unchanged forever, and every switch would fall back to the network
// default — the endpoint loss this map exists to prevent, with no
// self-healing. The allowedSites/deniedSites guards below are only
// read from, which is why they can be looser.
state.networkEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
// A profile written before this map existed carries exactly one pair
// of endpoints, belonging to whatever network it was last on. Adopt
// it as that network's remembered pair, so a custom endpoint set on
// the old build is not lost by the first switch away and back.
if (!state.networkEndpoints[state.networkId]) {
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
}
state.lastBalanceRefresh = saved.lastBalanceRefresh || 0; state.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
state.activeAddress = saved.activeAddress || null; state.activeAddress = saved.activeAddress || null;
state.allowedSites = state.allowedSites =

View File

@@ -0,0 +1,116 @@
// The base-unit amount an ERC-20 transfer from the wallet's own Send screen is
// encoded with.
//
// A token amount is a decimal string plus a scale, and the two come from
// different places. The confirmation screen renders the amount, the balance and
// the symbol from the block explorer's cached metadata (see
// fetchTokenBalances() in balances.js); the transfer used to be encoded from
// decimals() read off the contract at signing time, and nothing compared the
// two. A token whose on-chain scale differs from the cached one — an
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
// entry — therefore signed an amount that was never displayed, off by a power
// of ten for every decimal place of disagreement.
//
// So the scale used to encode is the scale the screen rendered with, carried
// forward on the pending transaction, and the contract's own answer is read
// only to be compared with it. A disagreement is a refusal, never a preference
// for either number: the wallet cannot tell which of the two the user meant,
// and both candidate transfers move an amount nobody approved.
//
// This is the confirmTx counterpart to approvalVerify.js, which does the same
// job for the dApp approval path, and it takes the same stance: a quantity that
// cannot be compared with what was displayed has not been checked, so an absent
// or unusable value is refused rather than filled in.
//
// Every message here is shown to the user on the transaction error screen, so
// each is a full sentence and names the numbers it is refusing over.
const { parseUnits } = require("ethers");
// Solidity's decimals() returns a uint8, so anything outside that range is not
// an answer this wallet can use.
const MAX_DECIMALS = 255;
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
"The transfer was not sent, because the number of decimal places this" +
" amount was shown with is unknown, so the amount that would be signed" +
" cannot be shown to be the amount that was displayed.";
const UNREADABLE_CONTRACT_DECIMALS_MESSAGE =
"The transfer was not sent, because the token contract did not report a" +
" usable number of decimal places, so the amount that would be signed" +
" cannot be checked against the amount that was displayed.";
function mismatchMessage(displayed, onChain) {
return (
"The transfer was not sent. The token contract reports " +
onChain +
" decimal places, but the amount was displayed using " +
displayed +
", so signing it would move a different amount than the one shown." +
" Reopen the wallet to reload this token's details and try again."
);
}
// A decimals value from either source as a number, or null if it is not one.
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
// as a string, so both of those are accepted alongside a plain number; anything
// fractional, negative, out of uint8 range, or of any other type at all is not.
//
// The types are enumerated rather than coerced because Number() is far too
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
// admit an empty array as a scale of zero and encode a whole-token transfer
// against it.
function toDecimals(value) {
let n;
if (typeof value === "number") {
n = value;
} else if (typeof value === "bigint") {
if (value < 0n || value > BigInt(MAX_DECIMALS)) return null;
n = Number(value);
} else if (typeof value === "string") {
if (!/^[0-9]+$/.test(value)) return null;
n = Number(value);
} else {
return null;
}
if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) return null;
return n;
}
// The decimals the confirmation screen rendered an amount with, as a number.
// Throws when the pending transaction does not carry a usable one — which is
// also what keeps the gas estimate from quietly estimating a different transfer
// than the one that would be signed.
function displayedDecimals(value) {
const displayed = toDecimals(value);
if (displayed === null) {
throw new Error(UNKNOWN_DISPLAYED_DECIMALS_MESSAGE);
}
return displayed;
}
// The transfer amount in the token's base units, or a throw. `amount` is the
// decimal string the user typed and the screen displayed, `displayed` is the
// scale it was displayed at, and `onChain` is what the contract's decimals()
// answered at signing time. The two scales must agree.
function transferAmountUnits(amount, displayed, onChain) {
const shown = displayedDecimals(displayed);
const reported = toDecimals(onChain);
if (reported === null) {
throw new Error(UNREADABLE_CONTRACT_DECIMALS_MESSAGE);
}
if (reported !== shown) {
throw new Error(mismatchMessage(shown, reported));
}
return parseUnits(String(amount), shown);
}
module.exports = {
displayedDecimals,
transferAmountUnits,
mismatchMessage,
MAX_DECIMALS,
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
};

View File

@@ -80,17 +80,12 @@ describe("alarms module", () => {
delete global.chrome; delete global.chrome;
}); });
test("ensureRecurringAlarms schedules both recurring jobs", async () => { test("ensureRecurringAlarms schedules the recurring job", async () => {
const created = await alarmsMod.ensureRecurringAlarms(); const created = await alarmsMod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, phishing: true }); expect(created).toEqual({ balance: true, cleared: [] });
const names = alarmsStub.created.map((c) => c.name).sort(); const names = alarmsStub.created.map((c) => c.name);
expect(names).toEqual( expect(names).toEqual([alarmsMod.BALANCE_REFRESH_ALARM]);
[
alarmsMod.BALANCE_REFRESH_ALARM,
alarmsMod.PHISHING_REFRESH_ALARM,
].sort(),
);
}); });
test("the balance refresh keeps its 60-second cadence", async () => { test("the balance refresh keeps its 60-second cadence", async () => {
@@ -99,12 +94,35 @@ describe("alarms module", () => {
expect(balance.periodInMinutes).toBe(1); expect(balance.periodInMinutes).toBe(1);
}); });
test("the phishing refresh keeps its 24-hour cadence", async () => { test("a retired job's alarm is cleared, not left running", async () => {
// The browser holds an alarm until something clears it. Deleting the
// job from the code is not enough: on every install that ever ran the
// version which created it, the alarm goes on waking the service
// worker on its old schedule with nothing to deliver it to.
for (const name of alarmsMod.OBSOLETE_ALARMS) {
alarmsStub.create(name, { periodInMinutes: 24 * 60 });
}
expect(alarmsMod.OBSOLETE_ALARMS.length).toBeGreaterThan(0);
const result = await alarmsMod.ensureRecurringAlarms();
expect(result.cleared).toEqual(alarmsMod.OBSOLETE_ALARMS);
for (const name of alarmsMod.OBSOLETE_ALARMS) {
expect(alarmsStub.alarms.get(name)).toBeUndefined();
}
});
test("clearing a retired alarm is not re-reported once it is gone", async () => {
await alarmsMod.ensureRecurringAlarms(); await alarmsMod.ensureRecurringAlarms();
const phishing = alarmsStub.alarms.get( const again = await alarmsMod.ensureRecurringAlarms();
alarmsMod.PHISHING_REFRESH_ALARM, expect(again.cleared).toEqual([]);
});
test("no retired name is also a live one", async () => {
// A name in both lists would be created and then cleared on every
// start, so the job it schedules would never fire.
expect(alarmsMod.OBSOLETE_ALARMS).not.toContain(
alarmsMod.BALANCE_REFRESH_ALARM,
); );
expect(phishing.periodInMinutes).toBe(24 * 60);
}); });
test("no period is below the browser-enforced minimum", async () => { test("no period is below the browser-enforced minimum", async () => {
@@ -122,14 +140,14 @@ describe("alarms module", () => {
test("a revived worker does not reset an existing alarm's schedule", async () => { test("a revived worker does not reset an existing alarm's schedule", async () => {
await alarmsMod.ensureRecurringAlarms(); await alarmsMod.ensureRecurringAlarms();
expect(alarmsStub.create).toHaveBeenCalledTimes(2); expect(alarmsStub.create).toHaveBeenCalledTimes(1);
// Every wake re-runs the startup path. Re-creating an alarm restarts // Every wake re-runs the startup path. Re-creating an alarm restarts
// its period, so a busy extension would push the next fire out // its period, so a busy extension would push the next fire out
// forever and the job would never run. // forever and the job would never run.
const again = await alarmsMod.ensureRecurringAlarms(); const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, phishing: false }); expect(again).toEqual({ balance: false, cleared: [] });
expect(alarmsStub.create).toHaveBeenCalledTimes(2); expect(alarmsStub.create).toHaveBeenCalledTimes(1);
}); });
test("a missing alarm is re-created on the next start", async () => { test("a missing alarm is re-created on the next start", async () => {
@@ -137,7 +155,7 @@ describe("alarms module", () => {
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM); await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
const again = await alarmsMod.ensureRecurringAlarms(); const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: true, phishing: false }); expect(again).toEqual({ balance: true, cleared: [] });
expect( expect(
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM), alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
).toBeDefined(); ).toBeDefined();
@@ -147,17 +165,17 @@ describe("alarms module", () => {
// An install carries its alarms across an extension update, so a // An install carries its alarms across an extension update, so a
// period changed in a new release only ever reaches users if the // period changed in a new release only ever reaches users if the
// stale one is reconciled. // stale one is reconciled.
alarmsStub.create(alarmsMod.PHISHING_REFRESH_ALARM, { alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
periodInMinutes: 7 * 24 * 60, periodInMinutes: 7 * 24 * 60,
}); });
alarmsStub.create.mockClear(); alarmsStub.create.mockClear();
const created = await alarmsMod.ensureRecurringAlarms(); const created = await alarmsMod.ensureRecurringAlarms();
expect(created.phishing).toBe(true); expect(created.balance).toBe(true);
expect( expect(
alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM) alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM)
.periodInMinutes, .periodInMinutes,
).toBe(alarmsMod.PHISHING_REFRESH_PERIOD_MINUTES); ).toBe(alarmsMod.BALANCE_REFRESH_PERIOD_MINUTES);
}); });
test("reconciling a period settles instead of re-creating forever", async () => { test("reconciling a period settles instead of re-creating forever", async () => {
@@ -168,31 +186,31 @@ describe("alarms module", () => {
alarmsStub.create.mockClear(); alarmsStub.create.mockClear();
const again = await alarmsMod.ensureRecurringAlarms(); const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, phishing: false }); expect(again).toEqual({ balance: false, cleared: [] });
expect(alarmsStub.create).not.toHaveBeenCalled(); expect(alarmsStub.create).not.toHaveBeenCalled();
}); });
test("handlers are dispatched by alarm name from one listener", () => { test("handlers are dispatched by alarm name from one listener", () => {
const balance = jest.fn(); const balance = jest.fn();
const phishing = jest.fn(); const other = jest.fn();
expect( expect(
alarmsMod.registerAlarmHandlers({ alarmsMod.registerAlarmHandlers({
[alarmsMod.BALANCE_REFRESH_ALARM]: balance, [alarmsMod.BALANCE_REFRESH_ALARM]: balance,
[alarmsMod.PHISHING_REFRESH_ALARM]: phishing, "autistmask-some-other-job": other,
}), }),
).toBe(true); ).toBe(true);
expect(alarmsStub.listenerCount()).toBe(1); expect(alarmsStub.listenerCount()).toBe(1);
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM); alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
expect(balance).toHaveBeenCalledTimes(1); expect(balance).toHaveBeenCalledTimes(1);
expect(phishing).not.toHaveBeenCalled(); expect(other).not.toHaveBeenCalled();
alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM); alarmsStub.fire("autistmask-some-other-job");
expect(phishing).toHaveBeenCalledTimes(1); expect(other).toHaveBeenCalledTimes(1);
alarmsStub.fire("some-other-extension-alarm"); alarmsStub.fire("an-alarm-with-no-handler");
expect(balance).toHaveBeenCalledTimes(1); expect(balance).toHaveBeenCalledTimes(1);
expect(phishing).toHaveBeenCalledTimes(1); expect(other).toHaveBeenCalledTimes(1);
}); });
test("Firefox MV2 gets the same treatment via browser.alarms", async () => { test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
@@ -205,8 +223,8 @@ describe("alarms module", () => {
try { try {
const mod = require("../src/shared/alarms"); const mod = require("../src/shared/alarms");
const created = await mod.ensureRecurringAlarms(); const created = await mod.ensureRecurringAlarms();
expect(created).toEqual({ balance: true, phishing: true }); expect(created).toEqual({ balance: true, cleared: [] });
expect(firefoxAlarms.created).toHaveLength(2); expect(firefoxAlarms.created).toHaveLength(1);
// The Chrome stub must not have been touched. // The Chrome stub must not have been touched.
expect(alarmsStub.create).not.toHaveBeenCalled(); expect(alarmsStub.create).not.toHaveBeenCalled();
} finally { } finally {
@@ -220,7 +238,7 @@ describe("alarms module", () => {
const mod = require("../src/shared/alarms"); const mod = require("../src/shared/alarms");
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({ await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
balance: false, balance: false,
phishing: false, cleared: [],
}); });
expect(mod.registerAlarmHandlers({})).toBe(false); expect(mod.registerAlarmHandlers({})).toBe(false);
}); });
@@ -274,9 +292,12 @@ function loadBackground(initialStore = {}) {
tabs: { query: jest.fn(), sendMessage: jest.fn() }, tabs: { query: jest.fn(), sendMessage: jest.fn() },
action: { setPopup: jest.fn() }, action: { setPopup: jest.fn() },
}; };
// Present so that a startup path which went to the network would be
// recorded rather than throwing, which is what makes "no request was made"
// an observation instead of an assumption.
global.fetch = jest.fn(async () => ({ global.fetch = jest.fn(async () => ({
ok: true, ok: true,
json: async () => ({ blacklist: [] }), json: async () => ({}),
})); }));
jest.resetModules(); jest.resetModules();
require("../src/background/index"); require("../src/background/index");
@@ -318,17 +339,21 @@ describe("background worker scheduling", () => {
// Let the startup path's promises settle. // Let the startup path's promises settle.
await settle(); await settle();
const names = alarmsStub.created.map((c) => c.name).sort(); const names = alarmsStub.created.map((c) => c.name);
const { const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
BALANCE_REFRESH_ALARM, expect(names).toEqual([BALANCE_REFRESH_ALARM]);
PHISHING_REFRESH_ALARM,
} = require("../src/shared/alarms");
expect(names).toEqual(
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
);
expect(mockSetIntervalCalls).toBe(0); expect(mockSetIntervalCalls).toBe(0);
}); });
test("startup contacts nothing", async () => {
// The phishing blocklist is vendored at build time and there is no
// other startup fetch, so a worker coming up asks nobody anything.
// Every wake used to be a candidate for a blocklist download.
loadBackground();
await settle();
expect(global.fetch).not.toHaveBeenCalled();
});
test("an onAlarm listener is installed on startup", async () => { test("an onAlarm listener is installed on startup", async () => {
alarmsStub = loadBackground().alarmsStub; alarmsStub = loadBackground().alarmsStub;
await settle(); await settle();
@@ -348,7 +373,7 @@ describe("background worker scheduling", () => {
alarmsStub.created.length = 0; alarmsStub.created.length = 0;
loaded.listeners.onStartup[0](); loaded.listeners.onStartup[0]();
await settle(); await settle();
expect(alarmsStub.created).toHaveLength(2); expect(alarmsStub.created).toHaveLength(1);
}); });
test("the install-time listener and the top-level call share one run", async () => { test("the install-time listener and the top-level call share one run", async () => {
@@ -360,13 +385,10 @@ describe("background worker scheduling", () => {
loaded.listeners.onInstalled[0](); loaded.listeners.onInstalled[0]();
await settle(); await settle();
expect(alarmsStub.created).toHaveLength(2); expect(alarmsStub.created).toHaveLength(1);
expect(alarmsStub.created.map((c) => c.name).sort()).toEqual( expect(alarmsStub.created.map((c) => c.name)).toEqual([
[
"autistmask-balance-refresh", "autistmask-balance-refresh",
"autistmask-phishing-refresh", ]);
].sort(),
);
}); });
}); });

View File

@@ -157,12 +157,9 @@ function loadBackground(options) {
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false, isPhishingDomain: () => false,
refreshPhishingListOnSchedule: jest.fn(async () => {}),
initPhishingList: jest.fn(async () => {}),
})); }));
jest.doMock("../src/shared/alarms", () => ({ jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance", BALANCE_REFRESH_ALARM: "balance",
PHISHING_REFRESH_ALARM: "phishing",
BALANCE_REFRESH_PERIOD_MINUTES: 1, BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}), ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(), registerAlarmHandlers: jest.fn(),

View File

@@ -0,0 +1,232 @@
// Who may move the active chain.
//
// wallet_switchEthereumChain used to be answered for any origin at all, with
// no connection check and no prompt, so a page the user had never connected
// to could clear the [TESTNET] banner under someone who believed they were
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). The refusal
// is asserted as a refusal to ACT — the state unmoved and no chainChanged
// broadcast — because an error code alone would not distinguish a gate from
// a switch that happened and then reported a failure.
//
// The endpoint half of that issue lives in tests/networkEndpoints.test.js;
// this file mocks the state module, which that one exercises for real.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The site the persisted state has connected, and one it has never heard of.
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const STRANGER_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node, so a switch that happens is visible as the loss of it.
const CUSTOM_RPC = "http://127.0.0.1:8545";
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
// Let the handler's promise chain run to the next suspension point. The gate
// reads storage before it answers, so the response is several awaits deep.
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// ---------------------------------------------------------------------------
// The gate: which origins the background will switch the chain for.
// ---------------------------------------------------------------------------
// Load the background worker against stubbed browser APIs, with the real
// chain-switch module behind it, and return the handles to drive it. The
// wallet state is a plain object so that a switch that DID happen is visible
// as a mutation of it, and one that did not is visible as its absence.
function loadBackground() {
jest.resetModules();
const walletState = {
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
networkEndpoints: {},
wallets: walletFixture(),
lastBalanceRefresh: 1,
tokenHolderCache: {},
fraudContracts: [],
};
jest.doMock("../src/shared/state", () => ({
state: walletState,
loadState: jest.fn(async () => {}),
saveState: jest.fn(async () => {}),
currentNetwork: () => networkById(walletState.networkId),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
const persisted = {
wallets: walletFixture(),
activeAddress: ADDRESS,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
};
let messageListener = null;
// Every message the background pushed at a content script. chainChanged
// is what tells a page the wallet moved, so an ungated switch is visible
// here as well as in the state.
const toTabs = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: persisted })),
set: jest.fn(async () => {}),
},
},
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
toTabs.push(message);
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function switchChain(chainId, origin) {
let result = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "wallet_switchEthereumChain",
params: [{ chainId }],
},
{ origin },
(r) => {
result = r;
},
);
await settle();
return result;
}
return {
switchChain,
walletState,
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
}
describe("wallet_switchEthereumChain is gated on the connection", () => {
test("an origin the wallet was never connected to is refused with 4100", async () => {
const bg = loadBackground();
const result = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
expect(result.result).toBeUndefined();
// The refusal has to be a refusal to ACT, not just an error string:
// the wallet is still on mainnet, still on the user's own node, and
// no page was told the chain moved.
expect(bg.walletState.networkId).toBe("mainnet");
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("an unconnected origin is refused even for the chain already active", async () => {
const bg = loadBackground();
const result = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
expect(result.error).toEqual({ code: 4100, message: "Unauthorized" });
});
test("an unconnected origin is refused before the unsupported-chain answer", async () => {
const bg = loadBackground();
const result = await bg.switchChain("0x89", STRANGER_ORIGIN);
expect(result.error.code).toBe(4100);
});
test("a connected origin switches the chain", async () => {
const bg = loadBackground();
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(result).toEqual({ result: null });
expect(bg.walletState.networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: SEPOLIA.chainId,
},
]);
});
test("a connected origin asking for an unsupported chain still gets 4902", async () => {
const bg = loadBackground();
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
expect(result.error.code).toBe(4902);
expect(bg.walletState.networkId).toBe("mainnet");
});
test("a switch by a connected origin keeps the user's endpoint", async () => {
const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(bg.walletState.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
});
});

View File

@@ -0,0 +1,212 @@
// What a chain switch does to a worker that has not loaded state yet.
//
// The MV3 service worker is terminated when idle and revived by the next
// message, and nothing loads state at module scope. The chain-switch handler
// reaches onChainSwitch(), which mutates the module-level `state` singleton
// and then persists EVERY field of it, so a handler that runs before a load
// writes DEFAULT_STATE over the user's stored profile — every wallet, every
// site approval, every tracked token and the custom endpoint
// (https://git.eeqj.de/sneak/AutistMask/issues/316). The same singleton is
// what currentNetwork() answers from, so the same-chain early return also
// compares against the wrong network.
//
// This file therefore uses the REAL state module and never calls loadState()
// itself: the handler has to do it. tests/chainSwitchGate.test.js mocks the
// state module wholesale and tests/networkEndpoints.test.js always loads
// first, so neither can see this.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node, and a wallet whose loss is the whole point.
const CUSTOM_RPC = "http://127.0.0.1:8545";
const CUSTOM_BLOCKSCOUT = "http://127.0.0.1:4000/api/v2";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
// A profile as an installed extension holds it, on `networkId`.
function storedProfile(networkId) {
return {
hasWallet: true,
wallets: walletFixture(),
activeAddress: ADDRESS,
networkId,
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
theme: "dark",
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// Load the background worker with the real state and chain-switch modules
// behind it, over a storage stub that actually keeps what is written — a
// wipe is only observable against storage that remembers.
function loadColdWorker(networkId) {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
const store = { autistmask: storedProfile(networkId) };
let messageListener = null;
const toTabs = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: store.autistmask })),
set: jest.fn(async (items) => {
store.autistmask = items.autistmask;
}),
},
},
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
toTabs.push(message);
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function switchChain(chainId) {
let result = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "wallet_switchEthereumChain",
params: [{ chainId }],
},
{ origin: CONNECTED_ORIGIN },
(r) => {
result = r;
},
);
await settle();
return result;
}
return {
switchChain,
persisted: () => store.autistmask,
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
}
describe("a chain switch on a worker that never loaded state", () => {
test("keeps the wallets, approvals, tokens and custom endpoint", async () => {
const bg = loadColdWorker("mainnet");
const result = await bg.switchChain(SEPOLIA.chainId);
expect(result).toEqual({ result: null });
const after = bg.persisted();
// The switch itself happened.
expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
// And it took nothing else with it. Without the load these come back
// as [], {}, [] and "system" from DEFAULT_STATE — every wallet in the
// extension gone, encrypted secrets included.
expect(after.wallets).toEqual(walletFixture());
expect(after.hasWallet).toBe(true);
expect(after.activeAddress).toBe(ADDRESS);
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_HOSTNAME] });
expect(after.trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
expect(after.theme).toBe("dark");
// The user's mainnet endpoint is remembered rather than replaced by
// the public default, so switching back returns it.
expect(after.networkEndpoints.mainnet).toEqual({
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
});
await bg.switchChain(MAINNET.chainId);
expect(bg.persisted().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
expect(bg.persisted().wallets).toEqual(walletFixture());
});
test("compares the requested chain against the stored one, not the default", async () => {
// Stored on Sepolia, asked for mainnet. Reading the unloaded
// singleton makes this look like the chain already active, so the
// page is told the switch succeeded while the wallet stays on the
// testnet it was on.
const bg = loadColdWorker("sepolia");
const result = await bg.switchChain(MAINNET.chainId);
expect(result).toEqual({ result: null });
expect(bg.persisted().networkId).toBe("mainnet");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: MAINNET.chainId,
},
]);
});
});

View File

@@ -13,7 +13,7 @@ const os = require("os");
const path = require("path"); const path = require("path");
const { chromium } = require("playwright-core"); const { chromium } = require("playwright-core");
const { installNetworkStubs } = require("./network"); const { installNetworkStubs, WORKER_PROBE_URL } = require("./network");
const REPO_ROOT = path.resolve(__dirname, "..", ".."); const REPO_ROOT = path.resolve(__dirname, "..", "..");
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome"); const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
@@ -129,42 +129,109 @@ function attachErrorListeners(ctx, errors) {
// if it ever stops being. // if it ever stops being.
} }
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// The most recently seen background worker, waiting for one if none has
// appeared yet. Most recent rather than first: Chrome stops an idle MV3
// worker and starts a fresh one on the next event, and a handle to a
// stopped worker cannot be evaluated in.
async function serviceWorker(ctx) { async function serviceWorker(ctx) {
const [existing] = ctx.serviceWorkers(); const workers = ctx.serviceWorkers();
if (existing) return existing; const latest = workers[workers.length - 1];
if (latest) return latest;
return ctx.waitForEvent("serviceworker", { timeout: 30000 }); return ctx.waitForEvent("serviceworker", { timeout: 30000 });
} }
// How long to wait for the background worker's first outbound request. // How long to wait for the probe request the worker is asked to make.
//
// The margin that actually decides whether this check is sound is not
// this timeout — it is whether the route handler is installed before the
// worker fetches. Measured over several runs: route installation
// completes 11-23ms after the context comes up, and the worker's
// blocklist fetch arrives 525-883ms after that, so the route wins by
// roughly 25-50x. This 30s figure is only slack for a loaded machine on
// top of that; losing the race fails the run rather than passing it
// quietly, which was verified by forcing a 3s delay before route
// installation.
const WORKER_TRAFFIC_TIMEOUT_MS = 30000; const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
// ctx.route() only sees service-worker requests when Playwright runs with // ctx.route() only sees service-worker requests when Playwright runs with
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e // PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
// sets. Without it the worker's traffic — notably the phishing blocklist // sets. Without it every fetch the background worker makes goes to the
// fetch src/background/index.js issues at startup — goes to the real // real internet and nothing says so. A harness whose isolation can lapse
// internet, and nothing says so, because src/shared/phishingDomains.js // in silence is worthless, so this does not take the flag on trust: a
// swallows fetch failures. A harness whose isolation can lapse in silence // request the worker itself issues has to show up in the route handler,
// is worthless, so this does not take the flag on trust: the background // or the suite refuses to run.
// worker's own startup fetch has to show up in the route handler, or the
// suite refuses to run.
// //
// Deliberately NOT a synthetic probe fetched through worker.evaluate(): // The anchor is a probe the harness asks the worker for, not traffic the
// evaluating in an extension worker this early kills it (the call fails // extension generates on its own. It used to be the phishing blocklist
// with "Target page, context or browser has been closed" and the worker // fetch src/background/index.js issued at startup; that fetch is gone —
// disappears), which would break the very thing being measured. Observing // the blocklist is vendored at build time and the extension contacts
// traffic the extension already generates costs nothing and cannot // nobody when it starts — so there is no longer any startup traffic to
// perturb it. // observe and the check generates its own.
async function assertWorkerTrafficIntercepted(stubs) { //
// Evaluating in the worker straight after launch does not work, and that
// is not a stale observation: it was tried again here and failed with
// "Target page, context or browser has been closed" on the first run.
// Chrome stops the freshly registered worker as soon as it has nothing to
// do, and the extension no longer gives it anything to do — which is the
// same change that removed the old anchor. So the probe wakes the worker
// before it evaluates in it, by sending it a message from an extension
// page and waiting for the reply: delivering a message is what starts a
// stopped worker, and a worker that has just answered one is alive.
// The evaluated fetch is not awaited, so nothing in the worker is held
// open by the probe either.
async function wakeWorker(ctx) {
const sw = await serviceWorker(ctx);
const extensionId = new URL(sw.url()).host;
const page = await ctx.newPage();
try {
await page.goto(
"chrome-extension://" + extensionId + "/src/popup/index.html",
);
// eth_chainId is answered from local state: it wakes the worker
// and changes nothing.
await page.evaluate(
() =>
new Promise((resolve) => {
chrome.runtime.sendMessage(
{
type: "AUTISTMASK_RPC",
method: "eth_chainId",
params: [],
},
() => resolve(null),
);
}),
);
} finally {
await page.close();
}
}
async function probeFromWorker(ctx, url) {
let lastError = null;
for (let attempt = 0; attempt < 5; attempt++) {
try {
await wakeWorker(ctx);
const sw = await serviceWorker(ctx);
await sw.evaluate((u) => {
// Deliberately not awaited and never rejected: what is
// being observed is that the request reaches the route
// handler, and an unhandled rejection in the worker would
// be collected as a suite error if it did not.
fetch(u).catch(() => {});
}, url);
return;
} catch (e) {
lastError = e;
await sleep(500);
}
}
throw new Error(
"could not ask the background worker to fetch " +
url +
", so service-worker interception was never tested. Last " +
"error: " +
(lastError && lastError.message),
);
}
async function assertWorkerTrafficIntercepted(ctx, stubs) {
await probeFromWorker(ctx, WORKER_PROBE_URL);
const seen = await stubs.waitForServiceWorkerTraffic( const seen = await stubs.waitForServiceWorkerTraffic(
WORKER_TRAFFIC_TIMEOUT_MS, WORKER_TRAFFIC_TIMEOUT_MS,
); );
@@ -177,19 +244,16 @@ async function assertWorkerTrafficIntercepted(stubs) {
throw new Error( throw new Error(
"observed no service-worker request in the route handler within " + "observed no service-worker request in the route handler within " +
WORKER_TRAFFIC_TIMEOUT_MS + WORKER_TRAFFIC_TIMEOUT_MS +
"ms. Under working interception the background worker's " + "ms, although the background worker was asked to fetch " +
"startup blocklist fetch (src/background/index.js) reaches the " + WORKER_PROBE_URL +
"handler about half a second after the route is installed. " + ". Two causes are plausible and this check cannot distinguish " +
"Two causes are plausible and this check cannot distinguish " +
"them: (1) service-worker interception is not in effect, so " + "them: (1) service-worker interception is not in effect, so " +
"that traffic went to the real internet unobserved — the suite " + "that request went to the real internet unobserved — the suite " +
"must be run through script/test-e2e, which sets " + "must be run through script/test-e2e, which sets " +
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " + "PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
"Playwright upgrade may have dropped or renamed that flag; " + "Playwright upgrade may have dropped or renamed that flag; " +
"(2) no worker request was made in the first place — the route " + "(2) the probe never ran, because the worker was torn down " +
"lost the startup race, or the worker no longer fetches at " + "between being handed over and being evaluated in. Either way " +
"startup, in which case this check needs a new anchor because " +
"there is no longer any worker traffic to observe. Either way " +
"the fix is a replacement mechanism or an honest downgrade of " + "the fix is a replacement mechanism or an honest downgrade of " +
"the isolation claims in tests/e2e/network.js and README.md — " + "the isolation claims in tests/e2e/network.js and README.md — " +
"not deleting this check", "not deleting this check",
@@ -241,7 +305,7 @@ async function launch(routeOpts) {
routeOpts.report = (text) => errors.record("network", text); routeOpts.report = (text) => errors.record("network", text);
const stubs = await installNetworkStubs(ctx, routeOpts); const stubs = await installNetworkStubs(ctx, routeOpts);
await assertWorkerTrafficIntercepted(stubs); await assertWorkerTrafficIntercepted(ctx, stubs);
// The extension id is derived from the unpacked path, so it // The extension id is derived from the unpacked path, so it
// changes and must never be hardcoded. It is the host part of the // changes and must never be hardcoded. It is the host part of the

View File

@@ -9,13 +9,12 @@
// //
// Service-worker coverage is not free: ctx.route() only sees worker // Service-worker coverage is not free: ctx.route() only sees worker
// traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in // traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in
// the environment, which script/test-e2e does. Without it the phishing // the environment, which script/test-e2e does. Without it every fetch the
// blocklist fetch that src/background/index.js issues at worker startup // MV3 background worker makes — the JSON-RPC calls behind every approval
// silently reaches raw.githubusercontent.com on the open internet, and // in this suite among them — goes to the real internet unobserved. That is
// src/shared/phishingDomains.js swallows the failure so nothing surfaces // not left to trust: waitForServiceWorkerTraffic() below backs the
// it. That is not left to trust: waitForServiceWorkerTraffic() below // launch-time canary in harness.js, which fails the entire suite if worker
// backs the launch-time canary in harness.js, which fails the entire // requests stop being visible here.
// suite if worker requests stop being visible here.
// //
// Anything not explicitly stubbed here is aborted AND reported to the // Anything not explicitly stubbed here is aborted AND reported to the
// error collector, so a newly added outbound call shows up as a test // error collector, so a newly added outbound call shows up as a test
@@ -103,6 +102,22 @@ function word(value) {
const DAPP_ORIGIN = "https://dapp.e2e.test"; const DAPP_ORIGIN = "https://dapp.e2e.test";
const DAPP_URL = DAPP_ORIGIN + "/"; const DAPP_URL = DAPP_ORIGIN + "/";
// The same page, served from a hostname that is on the vendored phishing
// blocklist, so the phishing warning can be driven end to end against the real
// list rather than a stub of it. It is a live entry at the pinned upstream
// commit; upstream prunes, so a re-vendoring run that retires it turns the
// phishing test red, and the fix is a current entry, not a weaker assertion.
const PHISHING_DAPP_ORIGIN = "https://myetheywallet.com";
const PHISHING_DAPP_URL = PHISHING_DAPP_ORIGIN + "/";
// A request the harness asks the background service worker to make, purely so
// that worker interception can be proved before any test runs. Nothing in the
// extension fetches at startup any more — the blocklist is vendored at build
// time — so the canary in harness.js has no product traffic to anchor on and
// generates its own. See assertWorkerTrafficIntercepted().
const WORKER_PROBE_ORIGIN = "https://worker-probe.e2e.test";
const WORKER_PROBE_URL = WORKER_PROBE_ORIGIN + "/canary";
// Requests are parked rather than awaited. An approval prompt only exists // Requests are parked rather than awaited. An approval prompt only exists
// while its call is in flight, so a test that awaited the promise could // while its call is in flight, so a test that awaited the promise could
// never drive the popup that has to settle it; start() files the promise // never drive the popup that has to settle it; start() files the promise
@@ -206,11 +221,6 @@ const RPC_RESULTS = {
eth_estimateGas: hex(GAS_LIMIT), eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0", eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI), eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
// "not mined yet", which is what a node answers for a transaction it has
// only just accepted. The wait screen the dApp transaction approval hands
// off to polls this every 10 seconds; leaving it unstubbed would report
// the poll as escaping traffic the moment a test outlived one tick.
eth_getTransactionReceipt: null,
}; };
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from // The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
@@ -238,17 +248,22 @@ function latestBlock() {
const SELECTOR_DECIMALS = "0x313ce567"; const SELECTOR_DECIMALS = "0x313ce567";
// Every eth_call still answers with a zero word except decimals() on the // Every eth_call still answers with a zero word except decimals() on the
// stub token. ethers reads that before it can encode an ERC-20 transfer, // stub token, which the wallet reads back at signing time to compare with
// and a zero there makes parseUnits() reject any fractional amount — so the // the scale the confirmation screen rendered (issue #305).
// ERC-20 confirmation path would fail its gas estimate for a reason that //
// has nothing to do with what is being tested. // opts.tokenDecimalsOverride is the lying contract: set it and decimals()
function ethCallResult(req) { // answers something other than the value this same fixture reports through
// Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has
// been built — without re-registering anything.
function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null; const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD; if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase(); const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase(); const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(STUB_TOKEN.decimals); return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
} }
return ZERO_WORD; return ZERO_WORD;
} }
@@ -331,6 +346,38 @@ function transactionDetails(hash) {
}; };
} }
// The receipt for a transaction this run broadcast.
//
// eth_getTransactionReceipt otherwise answers null — "not mined yet", which is
// what a node says about a transaction it has only just accepted, and what the
// wait screen has to keep polling through. opts.seedReceipt confirms it
// instead, which is how a test that drives the popup's own send to a broadcast
// gets off the wait screen: the wait resolves to the success view, which has a
// Done button, rather than polling for a receipt for the rest of the suite.
//
// Every field ethers' receipt formatter requires is present. A receipt it
// cannot parse throws inside the poll, which the wallet reports through
// log.errorf — i.e. console.error — and the harness fails the run on, so a
// half-populated fixture here would surface as an unrelated-looking failure.
function transactionReceipt(hash) {
return {
transactionHash: hash,
transactionIndex: "0x0",
blockHash: "0x" + "33".repeat(32),
blockNumber: hex(STUB_BLOCK_NUMBER),
from: STUB_COUNTERPARTY,
to: STUB_TOKEN.address,
cumulativeGasUsed: hex(GAS_LIMIT),
gasUsed: hex(GAS_LIMIT),
effectiveGasPrice: hex(GAS_PRICE_WEI),
contractAddress: null,
logs: [],
logsBloom: "0x" + "00".repeat(256),
status: "0x1",
type: "0x2",
};
}
function jsonResponse(route, body) { function jsonResponse(route, body) {
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
@@ -393,7 +440,13 @@ function rpcReply(req, opts, report) {
}); });
} }
if (req.method === "eth_call") { if (req.method === "eth_call") {
return Object.assign(envelope, { result: ethCallResult(req) }); return Object.assign(envelope, { result: ethCallResult(req, opts) });
}
if (req.method === "eth_getTransactionReceipt") {
const hash = Array.isArray(req.params) ? req.params[0] : null;
return Object.assign(envelope, {
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
});
} }
if (req.method === "eth_getBlockByNumber") { if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() }); return Object.assign(envelope, { result: latestBlock() });
@@ -540,6 +593,11 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed * @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order. * transaction handed to eth_sendRawTransaction, appended in order.
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* the stub token, in place of the value Blockscout reports for it. This is
* the token that lies about its scale; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>} * Promise<string|null>}>}
*/ */
@@ -555,10 +613,9 @@ async function installNetworkStubs(ctx, opts) {
// E2E_TRACE_NETWORK=1 prints every request that reaches this handler, // E2E_TRACE_NETWORK=1 prints every request that reaches this handler,
// tagged [sw] when it originated in the background service worker. // tagged [sw] when it originated in the background service worker.
// It exists so the isolation claim above can be re-checked by anyone // It exists so the isolation claim above can be re-checked by anyone
// in one command, without editing files: the phishing blocklist fetch // in one command, without editing files: the canary probe and then
// showing up with an [sw] tag is the proof that the worker really is // every JSON-RPC call behind an approval showing up with an [sw] tag
// intercepted and that the raw.githubusercontent.com stub below is // is the proof that the worker really is intercepted.
// live code rather than decoration.
const trace = traceEnabled(process.env.E2E_TRACE_NETWORK); const trace = traceEnabled(process.env.E2E_TRACE_NETWORK);
// Regex rather than a glob so chrome-extension:// resource loads are // Regex rather than a glob so chrome-extension:// resource loads are
@@ -589,7 +646,11 @@ async function installNetworkStubs(ctx, opts) {
// trips run against a real http(s) origin — which is what makes the // trips run against a real http(s) origin — which is what makes the
// shipped content scripts inject at all — without any remote origin // shipped content scripts inject at all — without any remote origin
// being involved. // being involved.
if (url.origin === DAPP_ORIGIN && p === "/") { if (
(url.origin === DAPP_ORIGIN ||
url.origin === PHISHING_DAPP_ORIGIN) &&
p === "/"
) {
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "text/html; charset=utf-8", contentType: "text/html; charset=utf-8",
@@ -635,18 +696,10 @@ async function installNetworkStubs(ctx, opts) {
return jsonResponse(route, { Data: {} }); return jsonResponse(route, { Data: {} });
} }
// MetaMask phishing blocklist // The interception canary's own request. Answered with nothing: what
if ( // is being observed is that it arrived here at all.
url.hostname === "raw.githubusercontent.com" || if (url.href === WORKER_PROBE_URL) {
p.endsWith("/eth-phishing-detect/main/src/config.json") return route.fulfill({ status: 204, body: "" });
) {
return jsonResponse(route, {
version: 2,
tolerance: 2,
fuzzylist: [],
whitelist: [],
blacklist: [],
});
} }
// Best-effort Etherscan address labels: served as an empty page. // Best-effort Etherscan address labels: served as an empty page.
@@ -667,12 +720,12 @@ async function installNetworkStubs(ctx, opts) {
* Resolve with the first service-worker-originated request this * Resolve with the first service-worker-originated request this
* handler saw, or null if none arrives within `ms`. * handler saw, or null if none arrives within `ms`.
* *
* The background worker fetches the phishing blocklist at * The caller asks the worker for one request of its own (see
* startup, unconditionally, within about a second of the context * WORKER_PROBE_URL) and then waits here, so under working
* coming up — so under working interception this resolves almost * interception this resolves almost immediately. Nothing arriving
* immediately. Nothing arriving means worker traffic is bypassing * means worker traffic is bypassing the handler entirely and going
* the handler entirely and going to the real internet, which the * to the real internet, which the caller turns into a hard failure
* caller turns into a hard failure of the whole suite. * of the whole suite.
*/ */
waitForServiceWorkerTraffic(ms) { waitForServiceWorkerTraffic(ms) {
if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest); if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest);
@@ -696,6 +749,9 @@ module.exports = {
DAPP_HTML, DAPP_HTML,
DAPP_ORIGIN, DAPP_ORIGIN,
DAPP_URL, DAPP_URL,
PHISHING_DAPP_ORIGIN,
PHISHING_DAPP_URL,
WORKER_PROBE_URL,
FEE_ESTIMATE_WEI, FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI, FEE_RESERVE_WEI,
STUB_COUNTERPARTY, STUB_COUNTERPARTY,

View File

@@ -12,10 +12,12 @@
const { const {
Transaction, Transaction,
formatEther, formatEther,
formatUnits,
getAddress, getAddress,
getBytes, getBytes,
hexlify, hexlify,
parseEther, parseEther,
parseUnits,
toQuantity, toQuantity,
toUtf8Bytes, toUtf8Bytes,
verifyMessage, verifyMessage,
@@ -33,6 +35,7 @@ const {
const { const {
DAPP_ORIGIN, DAPP_ORIGIN,
DAPP_URL, DAPP_URL,
PHISHING_DAPP_URL,
FEE_ESTIMATE_WEI, FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI, FEE_RESERVE_WEI,
STUB_COUNTERPARTY, STUB_COUNTERPARTY,
@@ -1190,8 +1193,9 @@ test("the theme and network selectors carry a non-default persisted value (#229)
// and a selector stuck on `dark`/`sepolia` would otherwise be // and a selector stuck on `dark`/`sepolia` would otherwise be
// indistinguishable here from one that persists correctly. Switching // indistinguishable here from one that persists correctly. Switching
// the network back also returns state.rpcUrl and state.blockscoutUrl // the network back also returns state.rpcUrl and state.blockscoutUrl
// to the mainnet defaults that onChainSwitch() overwrote, which are // to the mainnet endpoints onChainSwitch() remembered, which this
// the values src/shared/state.js starts with. // fixture never customised and so are the mainnet defaults
// src/shared/state.js starts with.
await env.page.selectOption("#settings-theme", "system"); await env.page.selectOption("#settings-theme", "system");
await env.page.selectOption("#settings-network", "mainnet"); await env.page.selectOption("#settings-network", "mainnet");
@@ -1963,6 +1967,208 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
); );
}); });
// ------------------------- the popup's own send, end to end (#305)
//
// Everything above this point stops at the confirmation screen. Nothing in
// the suite had ever clicked #btn-confirm-send, so the wallet's own Send ->
// ConfirmTx -> Sign & Send -> WaitTx path had no coverage at all, and issue
// #305 shipped through the gap: the screen was rendered from the explorer's
// decimals while the transfer was encoded from decimals() read off the
// contract at signing time, with nothing comparing the two.
//
// These two tests drive that path to a broadcast and read the amount out of
// the bytes the node was handed. The first asserts those bytes against what
// the screen displayed; the second makes the contract answer a different
// scale after the screen was built, and requires that nothing is broadcast.
// keccak("transfer(address,uint256)")[0:4].
const SELECTOR_TRANSFER = "0xa9059cbb";
// What decimals() starts answering once the confirmation screen has been
// built. The explorer reports 6 for the same token, so a wallet that encodes
// from the contract signs 10^12 times the amount it displayed.
const LYING_DECIMALS = "18";
const TOKEN_DECIMALS = Number(STUB_TOKEN.decimals);
// The transfer() call inside a raw signed transaction, hand-decoded.
//
// Deliberately not run through an ethers Interface built from the
// extension's own ABI: what is under assertion is the bytes that reached the
// node, and the fewer assumptions the wallet and the assertion share, the
// less room there is for both to be wrong in the same direction.
function decodeTransfer(rawSignedTx) {
const signed = Transaction.from(rawSignedTx);
const data = signed.data.toLowerCase();
assert(
data.startsWith(SELECTOR_TRANSFER) && data.length === 10 + 128,
"the broadcast transaction is not an ERC-20 transfer() call: " + data,
);
return {
signed,
recipient: getAddress("0x" + data.slice(34, 74)),
rawAmount: BigInt("0x" + data.slice(74)),
};
}
// The amount the confirmation screen is showing, verbatim.
async function shownAmount(page) {
return (await page.locator("#confirm-amount").innerText()).trim();
}
async function fillPasswordAndSend(page) {
await page.fill("#confirm-tx-password", PASSWORD);
await page.click("#btn-confirm-send");
}
async function goToTokenConfirm(env) {
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const shown = await shownAmount(env.page);
assert(
shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
"the confirmation screen is not showing the amount that was entered: " +
JSON.stringify(shown),
);
return shown;
}
test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", async (env) => {
// The previous test left the ETH balance at the fee-only fixture, which
// blocks sending outright; this one has to be able to press Send.
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
const shown = await goToTokenConfirm(env);
const before = env.routeOpts.broadcastTransactions.length;
// Confirm the transaction once it is broadcast, so the wait screen
// resolves to the success view instead of polling for the rest of the run.
env.routeOpts.seedReceipt = true;
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-wait-tx", 60000);
const broadcast = env.routeOpts.broadcastTransactions;
assert(
broadcast.length === before + 1,
"expected exactly one raw transaction to reach the RPC, got " +
(broadcast.length - before),
);
const { signed, recipient, rawAmount } = decodeTransfer(
broadcast[broadcast.length - 1],
);
// The measurement, printed on every run: the amount the user read, and
// what the signed bytes mean at each of the two candidate scales. Under
// the defect these three lines disagree.
console.log(
"# erc-20 send artifact: displayed=" +
JSON.stringify(shown) +
" rawAmount=" +
rawAmount +
" asIf" +
TOKEN_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" asIf" +
LYING_DECIMALS +
"Decimals=" +
formatUnits(rawAmount, Number(LYING_DECIMALS)),
);
assert(
getAddress(signed.to) === getAddress(STUB_TOKEN.address),
"the broadcast transaction does not call the token contract: " +
signed.to,
);
assert(
recipient === getAddress(STUB_COUNTERPARTY),
"the broadcast transfer goes to " + recipient,
);
// What the whole issue turns on: the signed amount, read back at the
// scale the SCREEN rendered with, is the number the screen rendered.
const wanted = parseUnits(shown.split(" ")[0], TOKEN_DECIMALS);
assert(
rawAmount === wanted,
"the broadcast transfer moves " +
rawAmount +
" base units, which is " +
formatUnits(rawAmount, TOKEN_DECIMALS) +
" " +
STUB_TOKEN.symbol +
" at the scale the confirmation screen displayed — but the screen" +
" displayed " +
JSON.stringify(shown) +
", i.e. " +
wanted +
" base units (#305)",
);
const summary = (
await env.page.locator("#wait-tx-summary").innerText()
).trim();
assert(
summary === shown,
"the wait screen summarises the send as " +
JSON.stringify(summary) +
", not as the approved " +
JSON.stringify(shown),
);
await visible(env.page, "#view-success-tx", 60000);
await env.page.click("#btn-success-tx-done");
await visible(env.page, "#view-address");
env.routeOpts.seedReceipt = false;
});
test("a token that lies about decimals() at signing time broadcasts nothing (#305)", async (env) => {
const shown = await goToTokenConfirm(env);
// Only now, with the screen already built and its estimate already taken
// at the explorer's scale, does the contract start answering differently.
// This is the whole shape of the defect: a value read at signing time that
// nothing on screen was ever derived from.
env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
const before = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-error-tx", 60000);
env.routeOpts.tokenDecimalsOverride = null;
assert(
env.routeOpts.broadcastTransactions.length === before,
"a transfer encoded against a contract that contradicts the " +
"confirmation screen still reached the RPC (#305)",
);
const message = (
await env.page.locator("#error-tx-message").innerText()
).trim();
console.log(
"# erc-20 decimals refusal: displayed=" +
JSON.stringify(shown) +
" contract=" +
LYING_DECIMALS +
" message=" +
JSON.stringify(message),
);
assert(
message.includes("reports " + LYING_DECIMALS + " decimal places") &&
message.includes("displayed using " + STUB_TOKEN.decimals),
"the refusal does not name both scales it is refusing over: " +
JSON.stringify(message),
);
assert(
/^[A-Z].*\.$/s.test(message),
"the refusal is not a full sentence: " + JSON.stringify(message),
);
await env.page.click("#btn-error-tx-done");
await visible(env.page, "#view-address");
});
// ------------------------------------------- dApp round trips (#183) // ------------------------------------------- dApp round trips (#183)
// //
// The seam. Everything above drives the popup on its own; this section is // The seam. Everything above drives the popup on its own; this section is
@@ -2069,9 +2275,9 @@ async function extensionActiveAddress(page) {
return getAddress(address); return getAddress(address);
} }
async function openDapp(ctx) { async function openDapp(ctx, url = DAPP_URL) {
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto(DAPP_URL); await page.goto(url);
// window.ethereum is not the fixture's doing — it is the shipped // window.ethereum is not the fixture's doing — it is the shipped
// MAIN-world content script. Waiting for it is waiting for the real // MAIN-world content script. Waiting for it is waiting for the real
// provider to have injected itself into a real http(s) origin. // provider to have injected itself into a real http(s) origin.
@@ -2543,6 +2749,15 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
JSON.stringify(hostname), JSON.stringify(hostname),
); );
// The control for the phishing test below: this origin is not on the
// blocklist, so the banner must be absent here. Without it a banner
// that was simply always visible would satisfy that test.
assert(
await popup.locator("#approve-site-phishing-warning").isHidden(),
"the phishing warning is showing for an origin that is not on " +
"the blocklist, so its appearance proves nothing",
);
// Deliberately not remembered: a remembered rejection lands the // Deliberately not remembered: a remembered rejection lands the
// origin in deniedSites and every later test in this section is // origin in deniedSites and every later test in this section is
// auto-rejected with no prompt at all, which would look like a pass. // auto-rejected with no prompt at all, which would look like a pass.
@@ -2606,6 +2821,53 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
); );
}); });
test("a connect request from a blocklisted site is flagged (#219)", async (env) => {
// The vendored blocklist, end to end: a real entry from the shipped
// artifact, served as a real http(s) origin, reaching the real background
// check and the real approval screen. Nothing about the list is stubbed —
// there is nothing left to stub, since the extension no longer fetches it.
const phishingDapp = await openDapp(env.ctx, PHISHING_DAPP_URL);
const hostname = new URL(PHISHING_DAPP_URL).hostname;
try {
await reserveApprovalTab(env);
await startRequest(
phishingDapp,
"phishing-accounts",
"eth_requestAccounts",
[],
);
const popup = await openSiteApprovalPopup(env);
try {
await visible(popup, "#view-approve-site");
const shown = await popup.locator("#approve-hostname").innerText();
assert(
shown === hostname,
"the site prompt names the wrong origin: " +
JSON.stringify(shown),
);
await visible(popup, "#approve-site-phishing-warning");
console.log("# phishing warning shown for " + hostname);
// Not remembered: a remembered decision for this origin would
// outlive the test.
await popup.uncheck("#approve-remember");
await popup.click("#btn-reject");
await assertUserRejection(
phishingDapp,
"phishing-accounts",
"the blocklisted site's eth_requestAccounts",
);
} finally {
await closeApprovalPages(env.ctx);
}
} finally {
await phishingDapp.close();
}
});
test("personal_sign signs, and the signature recovers to the address (#183)", async (env) => { test("personal_sign signs, and the signature recovers to the address (#183)", async (env) => {
await startRequest(env.dapp, "sign", "personal_sign", [ await startRequest(env.dapp, "sign", "personal_sign", [
SIGN_HEX, SIGN_HEX,
@@ -3037,6 +3299,13 @@ async function main() {
ethBalanceWei: null, ethBalanceWei: null,
failGasEstimate: false, failGasEstimate: false,
holdGasEstimate: false, holdGasEstimate: false,
// What decimals() answers for the stub token, when it is to answer
// something other than the value the same fixture reports through
// Blockscout. The token that lies about its scale (#305).
tokenDecimalsOverride: null,
// Whether eth_getTransactionReceipt confirms a transaction rather than
// answering "not mined yet".
seedReceipt: false,
// Every raw signed transaction handed to eth_sendRawTransaction, in // Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes // order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them. // rather than against anything the extension reported about them.

View File

@@ -0,0 +1,192 @@
// What a chain switch is allowed to do to the endpoints the user configured.
//
// A switch used to overwrite state.rpcUrl and state.blockscoutUrl with the
// network defaults, so a user pointing the wallet at their own node lost that
// url the first time anything switched chains — with no notification and no
// way to recover it, having been moved onto a public endpoint that then sees
// every address they hold (https://git.eeqj.de/sneak/AutistMask/issues/308).
// Endpoints are now remembered per network, which is why the round trips
// below assert the ORIGINAL url comes back rather than only that the switch
// happened.
const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node: the pair the switch used to throw away.
const CUSTOM_RPC = "http://127.0.0.1:8545";
const CUSTOM_BLOCKSCOUT = "http://127.0.0.1:4000/api/v2";
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
// The real state module against stubbed storage, plus whatever the last
// saveState() wrote — so a case can reload a fresh module from the bytes an
// earlier one persisted, which is what an extension restart does. `state` is
// a module-level singleton, so the registry has to be reset per load.
function loadModuleWith(persisted) {
jest.resetModules();
let written = null;
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set: jest.fn(async (items) => {
written = items.autistmask;
}),
},
},
};
return {
mod: require("../src/shared/state"),
chainSwitch: require("../src/shared/chainSwitch"),
written: () => written,
};
}
afterEach(() => {
delete global.chrome;
});
describe("a custom endpoint survives a chain switch", () => {
test("switching away and back restores the user's rpc and blockscout urls", async () => {
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
networkEndpoints: {
mainnet: {
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
},
},
});
await mod.loadState();
await chainSwitch.onChainSwitch("sepolia");
// The new chain gets its own endpoints, not the ones belonging to the
// chain just left: a mainnet node cannot answer for Sepolia.
expect(mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(mod.state.blockscoutUrl).toBe(SEPOLIA.defaultBlockscoutUrl);
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
test("an endpoint set on the network being left is remembered, not lost", async () => {
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "sepolia",
rpcUrl: SEPOLIA.defaultRpcUrl,
blockscoutUrl: SEPOLIA.defaultBlockscoutUrl,
networkEndpoints: {},
});
await mod.loadState();
// What the Settings screen does: write the live field, then save. The
// map entry for the active network is stale until the switch, which
// is what snapshotting the outgoing network exists to reconcile.
mod.state.rpcUrl = CUSTOM_RPC;
await mod.saveState();
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(MAINNET.defaultRpcUrl);
await chainSwitch.onChainSwitch("sepolia");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
});
test("the remembered endpoints survive an extension restart", async () => {
const first = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
});
await first.mod.loadState();
await first.chainSwitch.onChainSwitch("sepolia");
// Reload from exactly the bytes the switch persisted.
const second = loadModuleWith(first.written());
await second.mod.loadState();
expect(second.mod.state.networkId).toBe("sepolia");
expect(second.mod.state.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await second.chainSwitch.onChainSwitch("mainnet");
expect(second.mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(second.mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
test("a profile written before networkEndpoints existed keeps its endpoint", async () => {
// Exactly the stored shape the current release writes: one pair of
// urls and no map. It is adopted as the remembered pair of the
// network it was stored under.
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
});
await mod.loadState();
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.networkEndpoints).toEqual({
mainnet: { rpcUrl: CUSTOM_RPC, blockscoutUrl: CUSTOM_BLOCKSCOUT },
});
await chainSwitch.onChainSwitch("sepolia");
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
// A primitive is the dangerous case, not the array: assigning a property
// to a string throws nothing and stores nothing, so a stored string would
// be carried through loadState() and re-persisted by every save, and each
// switch would fall back to the public default in place of the user's
// endpoint, permanently.
test.each([
["an array", ["not", "a", "map"]],
["a string", "junk"],
["a number", 7],
])("a stored networkEndpoints that is %s is discarded", async (_, bad) => {
const { mod, chainSwitch } = loadModuleWith({
wallets: walletFixture(),
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
networkEndpoints: bad,
});
await mod.loadState();
// Discarded, then seeded from the live endpoints the same way an old
// profile is — never left as something onChainSwitch() would index.
expect(mod.state.networkEndpoints).toEqual({
mainnet: {
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
},
});
// And the endpoint really survives the round trip, which is the point
// of discarding it rather than only of the shape being right.
await chainSwitch.onChainSwitch("sepolia");
await chainSwitch.onChainSwitch("mainnet");
expect(mod.state.rpcUrl).toBe(CUSTOM_RPC);
expect(mod.state.blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
});
});

View File

@@ -1,93 +1,128 @@
// Extension storage stub for the Node test environment. The module resolves // The phishing blocklist is vendored at build time and shipped as digests:
// the storage API on use, so this only has to exist before the first call. // script/vendor-blocklist writes src/shared/phishingBlocklist.json, and nothing
// Values round-trip through JSON the way structured cloning would, so a test // fetches anything at runtime. Two things therefore have to be proven here, and
// cannot pass by holding a live reference to the module's own array. // the second is the one that would otherwise fail silently:
const storageStore = {}; //
global.chrome = { // - real domains from the vendored list are detected, and clean ones are not.
storage: { // - a malformed artifact fails loudly. Every way of getting the artifact
local: { // wrong produces a blocklist that matches nothing while looking healthy,
get: async (key) => // which is a phishing check that answers "no" to everything.
Object.prototype.hasOwnProperty.call(storageStore, key)
? { [key]: JSON.parse(JSON.stringify(storageStore[key])) }
: {},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
storageStore[key] = JSON.parse(JSON.stringify(value));
}
},
remove: async (key) => {
delete storageStore[key];
},
},
},
};
const { const {
isPhishingDomain, isPhishingDomain,
loadConfig,
getBlocklistSize, getBlocklistSize,
getDeltaSize,
hostnameVariants, hostnameVariants,
DELTA_STORAGE_KEY,
_reset,
_getVendoredBlacklistSize,
_getDeltaBlacklist,
} = require("../src/shared/phishingDomains"); } = require("../src/shared/phishingDomains");
const { HASH_HEX_CHARS, hashDomain } = require("../src/shared/domainHash");
const vendored = require("../src/shared/phishingBlocklist.json");
function clearStorage() { // Domains present in the vendored list at the pinned upstream commit. Upstream
for (const key of Object.keys(storageStore)) { // prunes as well as adds, so re-vendoring can retire one of these and turn this
delete storageStore[key]; // red; that is the intended prompt to pick a current entry, not a licence to
} // weaken the assertion into "some domain somewhere matches".
} const LISTED = [
"0-google.ph",
"myetheywallet.com",
// An underscore is not legal in a hostname, but DNS carries one and
// browsers resolve it, and upstream lists well over a hundred phishing
// sites that use one. The vendoring transform keeps them.
"phntum-wallett.godaddysites.com",
"coinbase_prologin1.godaddysites.com",
];
// The MV3 service worker is torn down when idle and re-evaluated on the next // Not on the list, and the kind of host a user actually visits.
// event, which wipes every module-level variable. Re-requiring the module with const CLEAN = ["etherscan.io", "example.com", "opensea.io", "sneak.berlin"];
// the registry reset is exactly that: fresh in-memory state, same extension
// storage underneath.
function restartWorker() {
jest.resetModules();
return require("../src/shared/phishingDomains");
}
// Reset delta state before each test to avoid cross-test contamination.
// Note: vendored sets are immutable and always present.
beforeEach(() => {
_reset();
clearStorage();
});
describe("phishingDomains", () => {
describe("vendored blocklist", () => { describe("vendored blocklist", () => {
test("vendored blacklist is loaded from bundled JSON", () => { test("the artifact holds the whole list", () => {
// The vendored blocklist should have a large number of entries
expect(_getVendoredBlacklistSize()).toBeGreaterThan(100000);
});
test("detects domains from vendored blacklist", () => {
// These are well-known phishing domains in the vendored list
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
});
test("getBlocklistSize includes vendored entries", () => {
expect(getBlocklistSize()).toBeGreaterThan(100000); expect(getBlocklistSize()).toBeGreaterThan(100000);
expect(vendored.hashes).toHaveLength(vendored.count * HASH_HEX_CHARS);
});
test("the digests are sorted and unique", () => {
// The lookup is a binary search over the concatenated digests. An
// unsorted or duplicated artifact would fail lookups quietly rather
// than loudly, so the ordering the search depends on is asserted here
// against the committed file rather than assumed of the generator.
// One assertion at the end rather than one per entry: 100k+ expect()
// calls cost seconds, and make test is capped at 30 for the whole
// suite. The index of the first offender is reported, so a failure
// still says where.
let previous = "";
let outOfOrderAt = -1;
for (let i = 0; i < vendored.count; i++) {
const at = vendored.hashes.slice(
i * HASH_HEX_CHARS,
(i + 1) * HASH_HEX_CHARS,
);
if (at <= previous) {
outOfOrderAt = i;
break;
}
previous = at;
}
expect(outOfOrderAt).toBe(-1);
});
test("every digest is lowercase hex of the declared width", () => {
expect(vendored.hashes).toMatch(/^[0-9a-f]*$/);
});
test("detects domains from the vendored list", () => {
for (const domain of LISTED) {
expect(isPhishingDomain(domain)).toBe(true);
}
});
test("does not flag legitimate domains", () => {
for (const domain of CLEAN) {
expect(isPhishingDomain(domain)).toBe(false);
}
});
test("detects a subdomain of a listed domain", () => {
expect(isPhishingDomain("wallet." + LISTED[0])).toBe(true);
expect(isPhishingDomain("a.b.c." + LISTED[0])).toBe(true);
});
test("matching is case-insensitive", () => {
expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true);
});
test("returns false for an empty or missing hostname", () => {
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
expect(isPhishingDomain(undefined)).toBe(false);
});
test("the first and last entries are both reachable", () => {
// The ends are where an off-by-one in a binary search hides: a search
// that never examines index 0 or index count-1 still finds everything
// in between, and the real list is not searched exhaustively here.
const first = vendored.hashes.slice(0, HASH_HEX_CHARS);
const last = vendored.hashes.slice(-HASH_HEX_CHARS);
const { _hashListed } = require("../src/shared/phishingDomains");
expect(_hashListed(first)).toBe(true);
expect(_hashListed(last)).toBe(true);
expect(_hashListed("0".repeat(HASH_HEX_CHARS))).toBe(false);
expect(_hashListed("f".repeat(HASH_HEX_CHARS))).toBe(false);
}); });
}); });
describe("hostnameVariants", () => { describe("hostnameVariants", () => {
test("returns exact hostname plus parent domains", () => { test("returns exact hostname plus parent domains", () => {
const variants = hostnameVariants("sub.evil.com"); expect(hostnameVariants("sub.evil.com")).toEqual([
expect(variants).toEqual(["sub.evil.com", "evil.com"]); "sub.evil.com",
"evil.com",
]);
}); });
test("returns just the hostname for a bare domain", () => { test("returns just the hostname for a bare domain", () => {
const variants = hostnameVariants("example.com"); expect(hostnameVariants("example.com")).toEqual(["example.com"]);
expect(variants).toEqual(["example.com"]);
}); });
test("handles deep subdomain chains", () => { test("handles deep subdomain chains", () => {
const variants = hostnameVariants("a.b.c.d.com"); expect(hostnameVariants("a.b.c.d.com")).toEqual([
expect(variants).toEqual([
"a.b.c.d.com", "a.b.c.d.com",
"b.c.d.com", "b.c.d.com",
"c.d.com", "c.d.com",
@@ -96,478 +131,89 @@ describe("phishingDomains", () => {
}); });
test("lowercases hostnames", () => { test("lowercases hostnames", () => {
const variants = hostnameVariants("Evil.COM"); expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
expect(variants).toEqual(["evil.com"]);
}); });
}); });
describe("delta computation via loadConfig", () => { describe("domain hashing", () => {
test("loadConfig computes delta of new entries not in vendored list", () => { test("a digest is the declared width of lowercase hex", () => {
loadConfig({ const hash = hashDomain("example.com");
blacklist: [ expect(hash).toHaveLength(HASH_HEX_CHARS);
"brand-new-scam-site-xyz123.com", expect(hash).toMatch(/^[0-9a-f]+$/);
"hopprotocol.pro", // already in vendored
],
});
// Only the new domain should be in the delta
expect(
_getDeltaBlacklist().has("brand-new-scam-site-xyz123.com"),
).toBe(true);
expect(_getDeltaBlacklist().has("hopprotocol.pro")).toBe(false);
expect(getDeltaSize()).toBe(1);
}); });
test("re-loading config replaces previous delta", () => { test("hashing is case-insensitive, so lookups are too", () => {
loadConfig({ expect(hashDomain("Evil.COM")).toBe(hashDomain("evil.com"));
blacklist: ["first-scam-xyz.com"],
});
expect(isPhishingDomain("first-scam-xyz.com")).toBe(true);
loadConfig({
blacklist: ["second-scam-xyz.com"],
});
expect(isPhishingDomain("first-scam-xyz.com")).toBe(false);
expect(isPhishingDomain("second-scam-xyz.com")).toBe(true);
}); });
test("getBlocklistSize includes both vendored and delta", () => { test("different domains get different digests", () => {
const baseSize = getBlocklistSize(); expect(hashDomain("evil.com")).not.toBe(hashDomain("evil.org"));
loadConfig({
blacklist: ["delta-only-scam-xyz.com"],
});
expect(getBlocklistSize()).toBe(baseSize + 1);
}); });
}); });
describe("isPhishingDomain with delta + vendored", () => { // A blocklist that silently matches nothing is the failure this module must not
test("detects domain from delta blacklist", () => { // have, so each way of breaking the artifact is required to throw at load. The
loadConfig({ // generator is the only thing that writes this file, but "the generator is
blacklist: ["fresh-scam-xyz.com"], // correct" is not something the shipped extension can check at runtime — this
}); // is what makes a format drift a build failure rather than a silent one.
expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true); describe("a malformed artifact fails loudly", () => {
}); const GOOD = {
algorithm: "sha256",
hashHexChars: HASH_HEX_CHARS,
count: 2,
hashes: "0".repeat(HASH_HEX_CHARS) + "1".repeat(HASH_HEX_CHARS),
};
test("detects domain from vendored blacklist", () => { function loadWith(artifact) {
// No delta loaded — vendored still works let mod;
expect(isPhishingDomain("hopprotocol.pro")).toBe(true); jest.isolateModules(() => {
jest.doMock(
"../src/shared/phishingBlocklist.json",
() => artifact,
{
virtual: false,
},
);
mod = require("../src/shared/phishingDomains");
}); });
return mod;
test("returns false for clean domains", () => {
expect(isPhishingDomain("etherscan.io")).toBe(false);
expect(isPhishingDomain("example.com")).toBe(false);
});
test("detects subdomain of blacklisted domain (vendored)", () => {
expect(isPhishingDomain("app.hopprotocol.pro")).toBe(true);
});
test("detects subdomain of blacklisted domain (delta)", () => {
loadConfig({
blacklist: ["delta-phish-xyz.com"],
});
expect(isPhishingDomain("sub.delta-phish-xyz.com")).toBe(true);
});
test("case-insensitive matching", () => {
loadConfig({
blacklist: ["Delta-Scam-XYZ.COM"],
});
expect(isPhishingDomain("delta-scam-xyz.com")).toBe(true);
expect(isPhishingDomain("DELTA-SCAM-XYZ.COM")).toBe(true);
});
test("returns false for empty/null hostname", () => {
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
});
test("handles config with no blacklist key", () => {
loadConfig({});
expect(getDeltaSize()).toBe(0);
// Vendored list still works
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
});
});
describe("extension storage persistence", () => {
test("delta is persisted to extension storage, not localStorage", async () => {
await loadConfig({
blacklist: ["persisted-scam-xyz.com"],
});
const stored = storageStore[DELTA_STORAGE_KEY];
expect(stored).toBeDefined();
expect(stored.blacklist).toContain("persisted-scam-xyz.com");
});
test("the fetch timestamp is persisted alongside the delta", async () => {
const before = Date.now();
await loadConfig({ blacklist: ["timestamped-scam-xyz.com"] });
const stored = storageStore[DELTA_STORAGE_KEY];
expect(typeof stored.lastFetchTime).toBe("number");
expect(stored.lastFetchTime).toBeGreaterThanOrEqual(before);
});
test("an oversized delta is dropped entirely, timestamp included", async () => {
// A record above the 256 KiB cap is not worth keeping; the
// timestamp goes with it so the next start re-fetches rather than
// claiming freshness for a delta that was never stored.
const huge = [];
for (let i = 0; i < 20000; i++) {
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
} }
await loadConfig({ blacklist: huge });
expect(storageStore[DELTA_STORAGE_KEY]).toBeUndefined();
});
test("delta is cleared on _reset", () => {
loadConfig({
blacklist: ["temp-scam-xyz.com"],
});
expect(getDeltaSize()).toBe(1);
_reset();
expect(getDeltaSize()).toBe(0);
});
});
describe("real-world blocklist patterns", () => {
test("detects known phishing domains from vendored list", () => {
expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true);
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
});
test("does not flag legitimate domains", () => {
expect(isPhishingDomain("opensea.io")).toBe(false);
expect(isPhishingDomain("etherscan.io")).toBe(false);
});
});
});
describe("phishing list across a service worker restart", () => {
beforeEach(() => {
clearStorage();
jest.resetModules();
});
afterEach(() => { afterEach(() => {
delete global.fetch; jest.dontMock("../src/shared/phishingBlocklist.json");
}); });
test("a revived worker restores the persisted delta without re-fetching", async () => { test("the control artifact loads", () => {
const first = require("../src/shared/phishingDomains"); expect(loadWith(GOOD).getBlocklistSize()).toBe(2);
await first.loadConfig({ blacklist: ["restart-scam-xyz.com"] });
const revived = restartWorker();
// Nothing in memory yet — this is a brand new module instance.
expect(revived.getDeltaSize()).toBe(0);
global.fetch = jest.fn();
await revived.initPhishingList();
expect(global.fetch).not.toHaveBeenCalled();
expect(revived.getDeltaSize()).toBe(1);
expect(revived.isPhishingDomain("restart-scam-xyz.com")).toBe(true);
}); });
test("repeated wakes inside the cache window never re-fetch", async () => { test("a different digest algorithm throws", () => {
const first = require("../src/shared/phishingDomains"); expect(() => loadWith({ ...GOOD, algorithm: "md5" })).toThrow(
await first.loadConfig({ blacklist: ["no-storm-scam-xyz.com"] }); /algorithm/,
global.fetch = jest.fn();
for (let i = 0; i < 5; i++) {
const revived = restartWorker();
await revived.initPhishingList();
}
expect(global.fetch).not.toHaveBeenCalled();
});
test("a persisted timestamp older than the TTL causes a fetch on startup", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["stale-scam-xyz.com"] });
// Age the persisted record past the 24-hour TTL.
storageStore[first.DELTA_STORAGE_KEY].lastFetchTime =
Date.now() - first.CACHE_TTL_MS - 1000;
const revived = restartWorker();
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["refreshed-scam-xyz.com"] }),
}));
await revived.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(revived.isPhishingDomain("refreshed-scam-xyz.com")).toBe(true);
expect(revived.isPhishingDomain("stale-scam-xyz.com")).toBe(false);
});
test("a first start with nothing persisted fetches immediately", async () => {
const fresh = restartWorker();
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["first-run-scam-xyz.com"] }),
}));
await fresh.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(fresh.isPhishingDomain("first-run-scam-xyz.com")).toBe(true);
});
test("updatePhishingList honours the persisted timestamp on its own", async () => {
// The startup path calls updatePhishingList() directly, so it must
// load persisted state itself rather than relying on anything else
// having finished first.
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });
const revived = restartWorker();
global.fetch = jest.fn();
await revived.updatePhishingList();
expect(global.fetch).not.toHaveBeenCalled();
expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
});
});
// The alarm period alone must set the cadence. lastFetchTime is stamped when
// the fetch completes, so it lands one fetch latency after the alarm that
// caused it; a freshness guard timed to the alarm period therefore vetoes
// every scheduled tick and halves the real refresh rate. These tests measure
// the interval between fetches that actually happened.
describe("phishing refresh steady-state cadence", () => {
const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms");
const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000;
let clockSpy;
let now;
beforeEach(() => {
clearStorage();
jest.resetModules();
now = Date.UTC(2026, 0, 1, 0, 0, 0);
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
clockSpy.mockRestore();
delete global.fetch;
});
function fetchStub(latencyMs, seen) {
return jest.fn(async () => {
seen.push(now);
// A network fetch takes time, and lastFetchTime is stamped after
// it, not when the alarm fired.
now += latencyMs;
return { ok: true, json: async () => ({ blacklist: [] }) };
});
}
test("ten alarm ticks produce ten fetches, one per period", async () => {
const fetchedAt = [];
global.fetch = fetchStub(5000, fetchedAt);
const startup = require("../src/shared/phishingDomains");
const T0 = now;
await startup.initPhishingList();
expect(fetchedAt).toEqual([T0]);
const TICKS = 10;
let tickAt = T0 + PERIOD_MS;
for (let i = 0; i < TICKS; i++) {
now = tickAt;
tickAt += PERIOD_MS;
// The browser wakes a terminated worker to deliver the alarm, so
// every tick starts from cold memory and the persisted record.
const revived = restartWorker();
await revived.refreshPhishingListOnSchedule();
}
expect(fetchedAt).toHaveLength(TICKS + 1);
const intervals = fetchedAt.slice(1).map((t, i) => t - fetchedAt[i]);
expect(intervals).toEqual(new Array(TICKS).fill(PERIOD_MS));
});
test("the scheduled tick fetches whatever the last fetch's latency was", async () => {
// The alarm fires one period after the previous alarm, which is
// `latency` short of one period since the fetch it caused completed.
for (const latency of [200, 1000, 5000]) {
clearStorage();
jest.resetModules();
storageStore[DELTA_STORAGE_KEY] = {
blacklist: [],
lastFetchTime: now - PERIOD_MS + latency,
lastAttemptTime: now - PERIOD_MS,
};
const mod = require("../src/shared/phishingDomains");
const fetchedAt = [];
global.fetch = fetchStub(latency, fetchedAt);
await mod.refreshPhishingListOnSchedule();
expect(fetchedAt).toHaveLength(1);
}
});
test("a worker wake inside the cache window still does not fetch", async () => {
// The TTL is not removed, only taken off the scheduled path. Chrome
// revives the worker every ~30 seconds and every revival runs the
// startup path, so the TTL still has to keep that off the network.
storageStore[DELTA_STORAGE_KEY] = {
blacklist: [],
lastFetchTime: now - PERIOD_MS + 5000,
lastAttemptTime: now - PERIOD_MS,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = jest.fn();
await mod.initPhishingList();
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe("phishing list timestamps that cannot be trusted", () => {
let clockSpy;
let now;
beforeEach(() => {
clearStorage();
jest.resetModules();
now = Date.UTC(2026, 0, 1, 0, 0, 0);
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
clockSpy.mockRestore();
delete global.fetch;
});
function okFetch() {
return jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["recovered-scam-xyz.com"] }),
}));
}
// jest.resetModules() clears the call record of a jest.fn, and simulating
// a worker restart is exactly that call. Anything counted across restarts
// has to be counted outside the mock.
function countingFetch(counter, response) {
return async () => {
counter.calls++;
return response();
};
}
test("a lastFetchTime in the future is discarded rather than trusted", async () => {
// Clock skew or a restored profile backup writes one. Every guard
// measures `Date.now() - stamp` and only tests the lower bound, so a
// stamp a year ahead would suppress updates for a year, and now that
// the value is persisted it would outlive every worker.
storageStore[DELTA_STORAGE_KEY] = {
blacklist: ["poisoned-scam-xyz.com"],
lastFetchTime: now + 365 * 24 * 60 * 60 * 1000,
lastAttemptTime: 0,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(mod.isPhishingDomain("recovered-scam-xyz.com")).toBe(true);
// And the record it leaves behind is sane, so recovery is permanent.
expect(
storageStore[DELTA_STORAGE_KEY].lastFetchTime,
).toBeLessThanOrEqual(now);
});
test("a lastAttemptTime in the future does not suppress the retry", async () => {
storageStore[DELTA_STORAGE_KEY] = {
lastAttemptTime: now + 365 * 24 * 60 * 60 * 1000,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
});
test("an oversized delta does not re-download on every worker wake", async () => {
// The delta and its freshness claim are both dropped, which is right,
// but nothing then says a fetch just happened. Chrome cycles the
// worker roughly every 30 seconds idle, so without the attempt stamp
// this is a full blocklist download per wake, forever.
const huge = [];
for (let i = 0; i < 20000; i++) {
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
}
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: true,
json: async () => ({ blacklist: huge }),
}));
for (let wake = 0; wake < 4; wake++) {
const revived = restartWorker();
await revived.initPhishingList();
now += 30 * 1000; // idle timeout, worker torn down and revived
}
expect(counter.calls).toBe(1);
expect(storageStore[DELTA_STORAGE_KEY].blacklist).toBeUndefined();
expect(typeof storageStore[DELTA_STORAGE_KEY].lastAttemptTime).toBe(
"number",
); );
}); });
test("a failing fetch is not retried on every worker wake either", async () => { test("a different digest width throws", () => {
const counter = { calls: 0 }; expect(() => loadWith({ ...GOOD, hashHexChars: 8 })).toThrow(
global.fetch = countingFetch(counter, () => ({ /hex characters per entry/,
ok: false, );
status: 503,
}));
for (let wake = 0; wake < 4; wake++) {
const revived = restartWorker();
await revived.initPhishingList();
now += 30 * 1000;
}
expect(counter.calls).toBe(1);
}); });
test("the retry floor expires, so a failure is not permanent", async () => { test("a count that does not match the string length throws", () => {
const { expect(() => loadWith({ ...GOOD, count: 3 })).toThrow(
MIN_FETCH_ATTEMPT_INTERVAL_MS, /which is not the/,
} = require("../src/shared/phishingDomains"); );
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: false,
status: 503,
}));
await restartWorker().initPhishingList();
expect(counter.calls).toBe(1);
// Still inside the floor: no retry.
now += MIN_FETCH_ATTEMPT_INTERVAL_MS - 1000;
await restartWorker().initPhishingList();
expect(counter.calls).toBe(1);
// Past it: the extension goes back to the network.
now += 2000;
await restartWorker().initPhishingList();
expect(counter.calls).toBe(2);
}); });
test("the scheduled tick ignores the retry floor", async () => { test("a missing hashes string throws", () => {
// The alarm period is far above the floor, but the floor exists to expect(() => loadWith({ ...GOOD, hashes: undefined })).toThrow(
// throttle wakes, not the schedule. /no hashes string/,
storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 }; );
const mod = require("../src/shared/phishingDomains"); });
global.fetch = okFetch();
await mod.refreshPhishingListOnSchedule(); test("an empty artifact throws rather than matching nothing", () => {
expect(global.fetch).toHaveBeenCalledTimes(1); expect(() => loadWith({ ...GOOD, count: 0, hashes: "" })).toThrow(
/entry count/,
);
}); });
}); });

View File

@@ -384,7 +384,7 @@ describe("the shipped token list", () => {
"0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD "0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
], ],
MUSD: [ MUSD: [
"0xaca92e438df0b2401ff60da7e4337b687a2435da", // MetaMask USD "0xaca92e438df0b2401ff60da7e4337b687a2435da",
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD "0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
], ],
JPYC: [ JPYC: [

View File

@@ -0,0 +1,128 @@
// The scale an ERC-20 transfer from the wallet's own Send screen is encoded
// with (issue #305). The screen renders from the block explorer's cached
// decimals; the transfer used to be encoded from decimals() read off the
// contract at signing time, with nothing comparing the two, so a token whose
// on-chain scale differed signed an amount that was never displayed.
const { parseUnits } = require("ethers");
const {
displayedDecimals,
transferAmountUnits,
MAX_DECIMALS,
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
} = require("../src/shared/transferAmount");
describe("displayedDecimals", () => {
test("accepts what the explorer and the contract each answer with", () => {
// A string is what fetchTokenBalances() parses out of Blockscout, a
// number is what it stores, and a bigint is what ethers hands back
// from a uint8 return.
expect(displayedDecimals("6")).toBe(6);
expect(displayedDecimals(6)).toBe(6);
expect(displayedDecimals(6n)).toBe(6);
expect(displayedDecimals(0)).toBe(0);
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
});
test("refuses anything that is not a uint8", () => {
for (const bad of [
null,
undefined,
"",
"eighteen",
NaN,
6.5,
-1,
MAX_DECIMALS + 1,
true,
{},
[],
]) {
expect(() => displayedDecimals(bad)).toThrow(
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
);
}
});
});
describe("transferAmountUnits", () => {
test("encodes with the displayed scale when the contract agrees", () => {
expect(transferAmountUnits("0.25", 6, 6n)).toBe(parseUnits("0.25", 6));
expect(transferAmountUnits("0.25", "6", 6n)).toBe(
parseUnits("0.25", 6),
);
expect(transferAmountUnits("1.5", 18, 18n)).toBe(parseUnits("1.5", 18));
});
// The reproduction on the issue: 0.25 of a token displayed at 6 decimals,
// signed against a contract answering 18, moves 10^12 times the amount
// that was approved.
test("refuses the reproduction rather than signing either amount", () => {
expect(() => transferAmountUnits("0.25", 6, 18n)).toThrow(
/contract reports 18 decimal places, but the amount was displayed using 6/,
);
});
test("refuses a disagreement in the other direction too", () => {
expect(() => transferAmountUnits("0.25", 18, 6n)).toThrow(
/contract reports 6 decimal places, but the amount was displayed using 18/,
);
});
test("never returns the amount at either scale on a disagreement", () => {
// The point of the refusal: both candidate encodings exist, and the
// wallet must produce neither.
let thrown = null;
try {
transferAmountUnits("0.25", 6, 18n);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(Error);
expect(thrown.message).toMatch(/was not sent/);
});
test("refuses when the screen's scale is unknown", () => {
expect(() => transferAmountUnits("0.25", null, 6n)).toThrow(
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
);
expect(() => transferAmountUnits("0.25", undefined, 6n)).toThrow(
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
);
});
test("refuses when the contract's answer is not a uint8", () => {
for (const bad of [null, undefined, "", "eighteen", 6.5, -1, 256]) {
expect(() => transferAmountUnits("0.25", 6, bad)).toThrow(
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
);
}
});
test("rejects an amount finer than the token's scale", () => {
// parseUnits' own refusal, reached only once the scales agree: a
// fractional base unit cannot be sent and must not be truncated.
expect(() => transferAmountUnits("0.0000001", 6, 6n)).toThrow();
});
test("every refusal is a full sentence", () => {
const messages = [];
for (const args of [
["0.25", 6, 18n],
["0.25", null, 6n],
["0.25", 6, "eighteen"],
]) {
try {
transferAmountUnits(...args);
} catch (e) {
messages.push(e.message);
}
}
expect(messages).toHaveLength(3);
for (const m of messages) {
expect(m).toMatch(/^[A-Z]/);
expect(m).toMatch(/\.$/);
}
});
});