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

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 two literals shipped code cannot
avoid. 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 7690fe6429
commit 722f7c86de
24 changed files with 1316 additions and 232496 deletions

View File

@@ -27,14 +27,9 @@ const {
TX_STAGE_NONCE,
} = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx");
const {
isPhishingDomain,
refreshPhishingListOnSchedule,
initPhishingList,
} = require("../shared/phishingDomains");
const { isPhishingDomain } = require("../shared/phishingDomains");
const {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
ensureRecurringAlarms,
registerAlarmHandlers,
@@ -1013,26 +1008,20 @@ async function backgroundRefresh() {
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,
// 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
// wake the worker to deliver them.
registerAlarmHandlers({
[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
// on a fresh install, on browser startup, and on every revival of a
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
// creates alarms that are missing or carrying a stale period, and
// initPhishingList() fetches only when the persisted timestamps say the list
// is stale.
// creates alarms that are missing or carrying a stale period, and only clears
// retired ones that are still registered.
//
// 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.
@@ -1043,10 +1032,7 @@ let backgroundJobsRun = null;
function startBackgroundJobs() {
if (backgroundJobsRun) return backgroundJobsRun;
backgroundJobsRun = Promise.all([
ensureRecurringAlarms(),
initPhishingList(),
])
backgroundJobsRun = ensureRecurringAlarms()
.catch((err) => {
// An alarm that failed to schedule means a recurring job silently
// never runs again; it must not be an unhandled rejection.