feat: vendor and censor the phishing blocklist at build time (closes #219)
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:
184
README.md
184
README.md
@@ -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 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user