feat: vendor and censor the phishing blocklist at build time (closes #219)
All checks were successful
check / check (push) Successful in 53s
e2e / e2e-chrome (push) Successful in 1m6s
e2e / e2e-firefox (push) Successful in 41s

The blocklist URL in shipped code named a competitor and pointed at a moving
ref, and the extension re-fetched from it every 24 hours, which also meant a
third party decided what this wallet warns about. All of that is gone.

script/vendor-blocklist fetches upstream at a pinned commit, verifies the
sha256 of the bytes that commit serves, and writes
src/shared/phishingBlocklist.json. It is build-time tooling, never shipped, and
the one place in the repo that names the upstream project; a source reference
nobody can verify is not a source reference.

The artifact stores truncated sha256 digests rather than domain names. That is
what censors it: the previous file contained the competitor's name 6,475 times,
as phishing domains impersonating them, and not one of those domains is
dropped. It also makes lookups a binary search over a fixed-width string, so
nothing is built at module load — which matters on MV3, where the worker
re-evaluates the module on every wake — and takes the file from 8.7 MB to
1.7 MB.

script/check-censored enforces the rest: it reads the name out of the vendoring
script rather than repeating it, and fails on any occurrence in the working
tree or under dist/ that is not one of the three literals shipped code cannot
avoid — two provider-shim identifiers in src/content/inpage.js and one ERC-20's
on-chain name in src/shared/tokenList.js. Each is permitted only at the path
that carries it, and at the emitted paths that path is bundled into, so a
literal appearing anywhere else fails like any other occurrence. It runs in
make check, which inspects dist/ when there is one and says loudly when there
is not, and again with --require-dist at the end of every make build.

Removing the runtime fetch retires the delta, the extension-storage persistence
and the 24-hour alarm from #158. A retired alarm is now cleared rather than
left waking the worker forever on installs that already have it.

The e2e suite drives the warning end to end from a real blocklisted origin
served as a real http(s) site, with a control asserting the banner stays hidden
for one that is not listed. Its service-worker interception canary needed a new
anchor, since the startup fetch it used to watch for no longer happens: it now
wakes the worker with a message and asks it for one throwaway fetch.

LICENSE no longer cites a repository that returns 404.

eslint.config.js gains one block: script/lib/ holds node programs the shell
entrypoints call, and without it they lint with no globals at all.
This commit is contained in:
2026-08-17 07:07:52 +00:00
parent 8fcdd8a053
commit 031a70e0b6
25 changed files with 1354 additions and 232497 deletions

184
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
blockchain interactions, a public CoinDesk API (no API key) for realtime price
information, and a Blockscout block-explorer API for transaction history and
token balances. It also fetches a community-maintained phishing domain blocklist
periodically and performs best-effort Etherscan address label lookups during
transaction confirmation.
token balances. It also performs best-effort Etherscan address label lookups
during transaction confirmation. A community-maintained phishing domain
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
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.
- `script/fmt` — format all files (writes)
- `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
`dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
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
`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
background worker's **own** startup request — the phishing blocklist fetch that
`src/background/index.js` issues on startup, which on the suite's throwaway
profile always happens because no previous fetch timestamp is persisted — to
arrive in the route handler, and aborts the entire suite if none does within 30
seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic
probe fetched from inside the worker via `worker.evaluate()` was tried first and
rejected, because evaluating in an extension service worker that early kills the
worker outright, destroying the thing being measured. Observing traffic the
extension already generates perturbs nothing. Losing the race fails closed — the
suite refuses to run rather than passing quietly.
background worker to exist, asks it for one throwaway `fetch()` of its own, and
requires that request to arrive in the route handler within 30 seconds or aborts
the entire suite (`tests/e2e/harness.js`). The anchor used to be the worker's
own startup traffic — the phishing blocklist fetch — and there is no longer any:
the blocklist is vendored at build time and the extension contacts nobody when
it starts. An earlier synthetic probe was rejected because evaluating in an
extension service worker immediately after launch killed the worker outright;
waiting for the worker to be handed over first, and issuing a `fetch()` that is
not awaited, does not. Failing the probe fails closed — the suite refuses to run
rather than passing quietly.
As defence in depth, Chrome is also started with
`--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
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
phishing blocklist refresh — are scheduled through the extension alarms API
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so
the balance refresh is expressed as exactly one minute and nothing is silently
slowed down.
again. The one recurring job — the 60-second balance refresh — is scheduled
through the extension alarms API (`src/shared/alarms.js`) instead. The browser
holds the schedule and wakes the worker to deliver it. Alarm periods are
clamped to a one-minute minimum, so the balance refresh is expressed as
exactly one minute and nothing is silently slowed down.
- Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage, including the timestamp of the
last phishing list fetch: without it a revived worker would either re-fetch on
every wake or, with a naive in-memory guard, never notice that an update is
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.
across a restart goes in extension storage. `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
alarm period it gates. Each guard is measured from the moment the last run
The job also carries a freshness guard, and a guard must never be timed to the
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
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
different reasons:
periods. 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 — and that
has to keep applying on the scheduled tick, so the guard is shortened to half
the alarm period rather than 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.
- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker
off the network on the wakes between scheduled refreshes — Chrome revives 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.
Retiring a job means clearing its alarm, not just deleting its handler. The
browser keeps an alarm until something removes it, so an install that once ran
the version which created it goes on being woken on that schedule forever. Names
that are no longer handled are listed in `OBSOLETE_ALARMS` and cleared on every
start; the 24-hour phishing blocklist refresh is there, retired when the runtime
fetch was removed.
Two timestamps are persisted for the phishing list, not one. `lastFetchTime`
records a fetch that produced a usable delta and drives the TTL.
`lastAttemptTime` records that the network was contacted at all, and is written
even when the result is unusable — a failed request, or a delta over the 256 KiB
cap. Without it those cases leave no freshness mark and the worker re-downloads
the full blocklist on every wake, indefinitely; with it, unscheduled retries are
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()`) runs on `onInstalled`, on
`onStartup`, and at the top level of the worker, so every way the background
context can start re-establishes the schedule. On a fresh install more than one
of those fires, so they share a single in-flight run rather than racing. It is
idempotent: an alarm that already exists with the period the code asks for is
left alone, because re-creating one restarts its schedule and a busy extension
would push the next fire out indefinitely. An alarm carrying a different period
— one created by an earlier version — is re-created once, or a period changed in
a new release would never reach an existing install.
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way
the background context can start re-establishes the schedule. On a fresh install
more than one of those fires, so they share a single in-flight run rather than
racing. It is idempotent: an alarm that already exists with the period the code
asks for is left alone, because re-creating one restarts its schedule and a busy
extension would push the next fire out indefinitely. An alarm carrying a
different period — one created by an earlier version — is re-created once, or a
period changed in 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
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,
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
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
@@ -1693,17 +1683,25 @@ indexes it as a real token transfer.
AutistMask protects users from known phishing sites when they connect their
wallet or approve transactions/signatures. A community-maintained domain
blocklist is vendored into the extension at build time, providing immediate
protection without any network requests. At runtime, the extension fetches the
live list once every 24 hours and keeps only the delta (newly added domains not
in the vendored list) in memory. This architecture keeps runtime memory usage
small while ensuring fresh coverage of new phishing domains.
blocklist is vendored into the extension at build time and checked entirely
locally: no network request is made for it, ever, so nobody learns which sites
the user connects to and no third party decides what this wallet warns about.
The 24-hour cadence is an alarm, not a timer; the alarm tick fetches
unconditionally rather than re-checking the 24-hour cache TTL that gates the
startup path; and the fetch timestamps live in extension storage rather than in
module variables — see [Background scheduling](#background-scheduling) for why
all three are required.
The trade is freshness. The shipped list is exactly as current as the last
vendoring run that was released, so a domain added upstream reaches users in the
next release rather than within a day. Refreshing it is `make vendor-blocklist`,
which fetches a hash-pinned upstream commit, verifies the sha256 of the bytes it
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
approval, or signature, the approval popup displays a prominent red warning
@@ -1799,18 +1797,24 @@ This repository includes data files from third-party projects that are not
covered by the GPL-3.0 license above. These files, their copyright holders, and
their licenses are:
| 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/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 |
| File | Source | Copyright | License |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `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 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
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
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
the runtime refresh both come from that upstream, whose URL is the
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
which project policy keeps out of code and documentation.
`script/vendor-blocklist` is the single definition and the only file that spells
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 two shipped-code literals it cannot
avoid — each permitted only at the one path that carries it, and listed in that
script's header.
## Author