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

@@ -33,6 +33,7 @@ const {
const {
DAPP_ORIGIN,
DAPP_URL,
PHISHING_DAPP_URL,
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,
@@ -2069,9 +2070,9 @@ async function extensionActiveAddress(page) {
return getAddress(address);
}
async function openDapp(ctx) {
async function openDapp(ctx, url = DAPP_URL) {
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
// MAIN-world content script. Waiting for it is waiting for the real
// provider to have injected itself into a real http(s) origin.
@@ -2473,6 +2474,15 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
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
// origin in deniedSites and every later test in this section is
// auto-rejected with no prompt at all, which would look like a pass.
@@ -2532,6 +2542,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) => {
await startRequest(env.dapp, "sign", "personal_sign", [
SIGN_HEX,