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.
159 lines
5.4 KiB
JavaScript
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,
|
|
};
|