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

@@ -13,7 +13,7 @@ const os = require("os");
const path = require("path");
const { chromium } = require("playwright-core");
const { installNetworkStubs } = require("./network");
const { installNetworkStubs, WORKER_PROBE_URL } = require("./network");
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
@@ -129,42 +129,109 @@ function attachErrorListeners(ctx, errors) {
// 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) {
const [existing] = ctx.serviceWorkers();
if (existing) return existing;
const workers = ctx.serviceWorkers();
const latest = workers[workers.length - 1];
if (latest) return latest;
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
}
// How long to wait for the background worker's first outbound request.
//
// 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.
// How long to wait for the probe request the worker is asked to make.
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
// ctx.route() only sees service-worker requests when Playwright runs with
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
// sets. Without it the worker's traffic — notably the phishing blocklist
// fetch src/background/index.js issues at startup — goes to the real
// internet, and nothing says so, because src/shared/phishingDomains.js
// swallows fetch failures. A harness whose isolation can lapse in silence
// is worthless, so this does not take the flag on trust: the background
// worker's own startup fetch has to show up in the route handler, or the
// suite refuses to run.
// sets. Without it every fetch the background worker makes goes to the
// real internet and nothing says so. A harness whose isolation can lapse
// in silence is worthless, so this does not take the flag on trust: a
// request the worker itself issues has to show up in the route handler,
// or the suite refuses to run.
//
// Deliberately NOT a synthetic probe fetched through worker.evaluate():
// evaluating in an extension worker this early kills it (the call fails
// with "Target page, context or browser has been closed" and the worker
// disappears), which would break the very thing being measured. Observing
// traffic the extension already generates costs nothing and cannot
// perturb it.
async function assertWorkerTrafficIntercepted(stubs) {
// The anchor is a probe the harness asks the worker for, not traffic the
// extension generates on its own. It used to be the phishing blocklist
// fetch src/background/index.js issued at startup; that fetch is gone —
// the blocklist is vendored at build time and the extension contacts
// nobody when it starts — so there is no longer any startup traffic to
// observe and the check generates its own.
//
// 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(
WORKER_TRAFFIC_TIMEOUT_MS,
);
@@ -177,19 +244,16 @@ async function assertWorkerTrafficIntercepted(stubs) {
throw new Error(
"observed no service-worker request in the route handler within " +
WORKER_TRAFFIC_TIMEOUT_MS +
"ms. Under working interception the background worker's " +
"startup blocklist fetch (src/background/index.js) reaches the " +
"handler about half a second after the route is installed. " +
"Two causes are plausible and this check cannot distinguish " +
"ms, although the background worker was asked to fetch " +
WORKER_PROBE_URL +
". Two causes are plausible and this check cannot distinguish " +
"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 " +
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
"Playwright upgrade may have dropped or renamed that flag; " +
"(2) no worker request was made in the first place — the route " +
"lost the startup race, or the worker no longer fetches at " +
"startup, in which case this check needs a new anchor because " +
"there is no longer any worker traffic to observe. Either way " +
"(2) the probe never ran, because the worker was torn down " +
"between being handed over and being evaluated in. Either way " +
"the fix is a replacement mechanism or an honest downgrade of " +
"the isolation claims in tests/e2e/network.js and README.md — " +
"not deleting this check",
@@ -241,7 +305,7 @@ async function launch(routeOpts) {
routeOpts.report = (text) => errors.record("network", text);
const stubs = await installNetworkStubs(ctx, routeOpts);
await assertWorkerTrafficIntercepted(stubs);
await assertWorkerTrafficIntercepted(ctx, stubs);
// The extension id is derived from the unpacked path, so it
// changes and must never be hardcoded. It is the host part of the