feat: vendor and censor the phishing blocklist at build time (closes #219) #301

Merged
clawbot merged 1 commits from issue-219-vendor-blocklist into next 2026-08-17 10:05:57 +02:00
25 changed files with 1354 additions and 232497 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/

178
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,
@@ -1412,17 +1413,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 +1683,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 +1798,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

30
TODO.md
View File

@@ -72,6 +72,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 +123,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,
@@ -1052,26 +1047,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 +1071,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

@@ -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,

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

@@ -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

@@ -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
@@ -555,10 +570,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 +603,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 +653,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 +677,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 +706,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

@@ -33,6 +33,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,
@@ -2069,9 +2070,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 +2544,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 +2616,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,

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: [