// 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, };