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.
220 lines
8.0 KiB
JavaScript
220 lines
8.0 KiB
JavaScript
// The phishing blocklist is vendored at build time and shipped as digests:
|
|
// script/vendor-blocklist writes src/shared/phishingBlocklist.json, and nothing
|
|
// fetches anything at runtime. Two things therefore have to be proven here, and
|
|
// the second is the one that would otherwise fail silently:
|
|
//
|
|
// - real domains from the vendored list are detected, and clean ones are not.
|
|
// - a malformed artifact fails loudly. Every way of getting the artifact
|
|
// wrong produces a blocklist that matches nothing while looking healthy,
|
|
// which is a phishing check that answers "no" to everything.
|
|
|
|
const {
|
|
isPhishingDomain,
|
|
getBlocklistSize,
|
|
hostnameVariants,
|
|
} = require("../src/shared/phishingDomains");
|
|
const { HASH_HEX_CHARS, hashDomain } = require("../src/shared/domainHash");
|
|
const vendored = require("../src/shared/phishingBlocklist.json");
|
|
|
|
// Domains present in the vendored list at the pinned upstream commit. Upstream
|
|
// prunes as well as adds, so re-vendoring can retire one of these and turn this
|
|
// red; that is the intended prompt to pick a current entry, not a licence to
|
|
// weaken the assertion into "some domain somewhere matches".
|
|
const LISTED = [
|
|
"0-google.ph",
|
|
"myetheywallet.com",
|
|
// An underscore is not legal in a hostname, but DNS carries one and
|
|
// browsers resolve it, and upstream lists well over a hundred phishing
|
|
// sites that use one. The vendoring transform keeps them.
|
|
"phntum-wallett.godaddysites.com",
|
|
"coinbase_prologin1.godaddysites.com",
|
|
];
|
|
|
|
// Not on the list, and the kind of host a user actually visits.
|
|
const CLEAN = ["etherscan.io", "example.com", "opensea.io", "sneak.berlin"];
|
|
|
|
describe("vendored blocklist", () => {
|
|
test("the artifact holds the whole list", () => {
|
|
expect(getBlocklistSize()).toBeGreaterThan(100000);
|
|
expect(vendored.hashes).toHaveLength(vendored.count * HASH_HEX_CHARS);
|
|
});
|
|
|
|
test("the digests are sorted and unique", () => {
|
|
// The lookup is a binary search over the concatenated digests. An
|
|
// unsorted or duplicated artifact would fail lookups quietly rather
|
|
// than loudly, so the ordering the search depends on is asserted here
|
|
// against the committed file rather than assumed of the generator.
|
|
// One assertion at the end rather than one per entry: 100k+ expect()
|
|
// calls cost seconds, and make test is capped at 30 for the whole
|
|
// suite. The index of the first offender is reported, so a failure
|
|
// still says where.
|
|
let previous = "";
|
|
let outOfOrderAt = -1;
|
|
for (let i = 0; i < vendored.count; i++) {
|
|
const at = vendored.hashes.slice(
|
|
i * HASH_HEX_CHARS,
|
|
(i + 1) * HASH_HEX_CHARS,
|
|
);
|
|
if (at <= previous) {
|
|
outOfOrderAt = i;
|
|
break;
|
|
}
|
|
previous = at;
|
|
}
|
|
expect(outOfOrderAt).toBe(-1);
|
|
});
|
|
|
|
test("every digest is lowercase hex of the declared width", () => {
|
|
expect(vendored.hashes).toMatch(/^[0-9a-f]*$/);
|
|
});
|
|
|
|
test("detects domains from the vendored list", () => {
|
|
for (const domain of LISTED) {
|
|
expect(isPhishingDomain(domain)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("does not flag legitimate domains", () => {
|
|
for (const domain of CLEAN) {
|
|
expect(isPhishingDomain(domain)).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("detects a subdomain of a listed domain", () => {
|
|
expect(isPhishingDomain("wallet." + LISTED[0])).toBe(true);
|
|
expect(isPhishingDomain("a.b.c." + LISTED[0])).toBe(true);
|
|
});
|
|
|
|
test("matching is case-insensitive", () => {
|
|
expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true);
|
|
});
|
|
|
|
test("returns false for an empty or missing hostname", () => {
|
|
expect(isPhishingDomain("")).toBe(false);
|
|
expect(isPhishingDomain(null)).toBe(false);
|
|
expect(isPhishingDomain(undefined)).toBe(false);
|
|
});
|
|
|
|
test("the first and last entries are both reachable", () => {
|
|
// The ends are where an off-by-one in a binary search hides: a search
|
|
// that never examines index 0 or index count-1 still finds everything
|
|
// in between, and the real list is not searched exhaustively here.
|
|
const first = vendored.hashes.slice(0, HASH_HEX_CHARS);
|
|
const last = vendored.hashes.slice(-HASH_HEX_CHARS);
|
|
const { _hashListed } = require("../src/shared/phishingDomains");
|
|
expect(_hashListed(first)).toBe(true);
|
|
expect(_hashListed(last)).toBe(true);
|
|
expect(_hashListed("0".repeat(HASH_HEX_CHARS))).toBe(false);
|
|
expect(_hashListed("f".repeat(HASH_HEX_CHARS))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("hostnameVariants", () => {
|
|
test("returns exact hostname plus parent domains", () => {
|
|
expect(hostnameVariants("sub.evil.com")).toEqual([
|
|
"sub.evil.com",
|
|
"evil.com",
|
|
]);
|
|
});
|
|
|
|
test("returns just the hostname for a bare domain", () => {
|
|
expect(hostnameVariants("example.com")).toEqual(["example.com"]);
|
|
});
|
|
|
|
test("handles deep subdomain chains", () => {
|
|
expect(hostnameVariants("a.b.c.d.com")).toEqual([
|
|
"a.b.c.d.com",
|
|
"b.c.d.com",
|
|
"c.d.com",
|
|
"d.com",
|
|
]);
|
|
});
|
|
|
|
test("lowercases hostnames", () => {
|
|
expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
|
|
});
|
|
});
|
|
|
|
describe("domain hashing", () => {
|
|
test("a digest is the declared width of lowercase hex", () => {
|
|
const hash = hashDomain("example.com");
|
|
expect(hash).toHaveLength(HASH_HEX_CHARS);
|
|
expect(hash).toMatch(/^[0-9a-f]+$/);
|
|
});
|
|
|
|
test("hashing is case-insensitive, so lookups are too", () => {
|
|
expect(hashDomain("Evil.COM")).toBe(hashDomain("evil.com"));
|
|
});
|
|
|
|
test("different domains get different digests", () => {
|
|
expect(hashDomain("evil.com")).not.toBe(hashDomain("evil.org"));
|
|
});
|
|
});
|
|
|
|
// A blocklist that silently matches nothing is the failure this module must not
|
|
// have, so each way of breaking the artifact is required to throw at load. The
|
|
// generator is the only thing that writes this file, but "the generator is
|
|
// correct" is not something the shipped extension can check at runtime — this
|
|
// is what makes a format drift a build failure rather than a silent one.
|
|
describe("a malformed artifact fails loudly", () => {
|
|
const GOOD = {
|
|
algorithm: "sha256",
|
|
hashHexChars: HASH_HEX_CHARS,
|
|
count: 2,
|
|
hashes: "0".repeat(HASH_HEX_CHARS) + "1".repeat(HASH_HEX_CHARS),
|
|
};
|
|
|
|
function loadWith(artifact) {
|
|
let mod;
|
|
jest.isolateModules(() => {
|
|
jest.doMock(
|
|
"../src/shared/phishingBlocklist.json",
|
|
() => artifact,
|
|
{
|
|
virtual: false,
|
|
},
|
|
);
|
|
mod = require("../src/shared/phishingDomains");
|
|
});
|
|
return mod;
|
|
}
|
|
|
|
afterEach(() => {
|
|
jest.dontMock("../src/shared/phishingBlocklist.json");
|
|
});
|
|
|
|
test("the control artifact loads", () => {
|
|
expect(loadWith(GOOD).getBlocklistSize()).toBe(2);
|
|
});
|
|
|
|
test("a different digest algorithm throws", () => {
|
|
expect(() => loadWith({ ...GOOD, algorithm: "md5" })).toThrow(
|
|
/algorithm/,
|
|
);
|
|
});
|
|
|
|
test("a different digest width throws", () => {
|
|
expect(() => loadWith({ ...GOOD, hashHexChars: 8 })).toThrow(
|
|
/hex characters per entry/,
|
|
);
|
|
});
|
|
|
|
test("a count that does not match the string length throws", () => {
|
|
expect(() => loadWith({ ...GOOD, count: 3 })).toThrow(
|
|
/which is not the/,
|
|
);
|
|
});
|
|
|
|
test("a missing hashes string throws", () => {
|
|
expect(() => loadWith({ ...GOOD, hashes: undefined })).toThrow(
|
|
/no hashes string/,
|
|
);
|
|
});
|
|
|
|
test("an empty artifact throws rather than matching nothing", () => {
|
|
expect(() => loadWith({ ...GOOD, count: 0, hashes: "" })).toThrow(
|
|
/entry count/,
|
|
);
|
|
});
|
|
});
|