Files
AutistMask/src/shared/phishingDomains.js
sneak f3a18d2154
Some checks failed
check / check (push) Has been cancelled
e2e / e2e-chrome (push) Has been cancelled
e2e / e2e-firefox (push) Has been cancelled
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 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.
2026-08-17 07:15:57 +00:00

159 lines
5.4 KiB
JavaScript

// Domain-based phishing detection against a blocklist vendored at build time.
//
// The list is produced by script/vendor-blocklist from a hash-pinned upstream
// commit, committed as phishingBlocklist.json, and bundled. There is no runtime
// fetch: the extension asks nobody anything to answer this question, so no third
// party learns which sites a user connects to, and no third party decides what
// this wallet warns about. The cost is staleness — the shipped list is exactly
// as fresh as the last vendoring run that was released — and the refresh path is
// re-running that script and shipping the diff.
//
// The artifact holds digests, not domains: sha256 truncated to 64 bits, one
// entry per 16 hex characters, concatenated in sorted order into a single
// string (see domainHash.js). Three things follow from that shape, and all
// three are the reason for it:
//
// - the extension ships no plaintext list of anyone's domain names, which is
// what makes a blocklist assembled elsewhere shippable here at all.
// - a lookup is a binary search over that string. Nothing is built at module
// load, which matters because the MV3 service worker is torn down when idle
// and re-evaluates this file on every wake.
// - the file is 1.7 MB rather than 8.7 MB.
//
// Nothing here is async: callers answer an approval prompt with the result.
const vendored = require("./phishingBlocklist.json");
const { HASH_ALGORITHM, HASH_HEX_CHARS, hashDomain } = require("./domainHash");
// The artifact is generated, so a shape it does not have is a build fault, not
// a runtime condition. It is checked anyway, and loudly, because every way of
// getting it wrong — a stale format, a truncated file, a different digest —
// produces a blocklist that matches nothing at all while looking perfectly
// healthy. A phishing check that silently answers "no" to everything is the one
// failure this module must not have.
function checkArtifact(a) {
const bad = (why) =>
new Error(
"phishingBlocklist.json " +
why +
". It is generated by script/vendor-blocklist; re-run that " +
"rather than editing it.",
);
if (!a || typeof a !== "object") throw bad("is not an object");
if (a.algorithm !== HASH_ALGORITHM) {
throw bad(
"declares algorithm " +
JSON.stringify(a.algorithm) +
", but this build hashes with " +
HASH_ALGORITHM,
);
}
if (a.hashHexChars !== HASH_HEX_CHARS) {
throw bad(
"declares " +
JSON.stringify(a.hashHexChars) +
" hex characters per entry, but this build produces " +
HASH_HEX_CHARS,
);
}
if (typeof a.hashes !== "string") throw bad("has no hashes string");
if (!Number.isInteger(a.count) || a.count < 1) {
throw bad("declares no usable entry count");
}
if (a.hashes.length !== a.count * HASH_HEX_CHARS) {
throw bad(
"holds " +
a.hashes.length +
" hex characters, which is not the " +
a.count * HASH_HEX_CHARS +
" its count of " +
a.count +
" entries requires",
);
}
}
checkArtifact(vendored);
const HASHES = vendored.hashes;
const COUNT = vendored.count;
/**
* Is this digest one of the vendored entries?
*
* Binary search over fixed-width records. The digests are lowercase hex of one
* width, so lexicographic order is numeric order and the artifact is written
* sorted; tests assert that ordering against the committed file, because an
* unsorted artifact would fail lookups silently rather than loudly.
*
* @param {string} hash
* @returns {boolean}
*/
function hashListed(hash) {
let lo = 0;
let hi = COUNT - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const at = HASHES.slice(
mid * HASH_HEX_CHARS,
(mid + 1) * HASH_HEX_CHARS,
);
if (at === hash) return true;
if (at < hash) lo = mid + 1;
else hi = mid - 1;
}
return false;
}
/**
* Generate hostname variants for subdomain matching.
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
*
* @param {string} hostname
* @returns {string[]}
*/
function hostnameVariants(hostname) {
const h = hostname.toLowerCase();
const variants = [h];
const parts = h.split(".");
// Parent domains: a.b.c.d -> b.c.d, c.d
for (let i = 1; i < parts.length - 1; i++) {
variants.push(parts.slice(i).join("."));
}
return variants;
}
/**
* Check if a hostname is on the phishing blocklist.
*
* @param {string} hostname - The hostname to check.
* @returns {boolean}
*/
function isPhishingDomain(hostname) {
if (!hostname) return false;
for (const variant of hostnameVariants(hostname)) {
if (hashListed(hashDomain(variant))) return true;
}
return false;
}
/**
* Return the blocklist size for diagnostics.
*
* @returns {number}
*/
function getBlocklistSize() {
return COUNT;
}
module.exports = {
isPhishingDomain,
getBlocklistSize,
hostnameVariants,
// Exposed for testing only: the ends of the search range are where an
// off-by-one hides, and reaching them through isPhishingDomain() would mean
// knowing which domain hashes to the first or last entry.
_hashListed: hashListed,
};