Files
AutistMask/src/shared/phishingDomains.js
T
clawbot b4059af827
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: a listed site ending in a dot gets the phishing warning (closes #534)
The blocklist check hashed the hostname as the page gave it, so
"myetheywallet.com." and every subdomain written that way hashed
differently from the listed "myetheywallet.com" and opened approval
prompts with no warning. The browser and DNS treat both spellings as
one host. hostnameVariants() now drops one trailing dot before
building the hostname and its parent domains, so the check sees the
name the list holds.

Model: opus-5-5
2026-10-14 10:48:41 +02:00

162 lines
5.5 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"].
*
* A trailing dot is dropped: "evil.com." is the fully qualified spelling of
* "evil.com", and the browser loads the same site from either.
*
* @param {string} hostname
* @returns {string[]}
*/
function hostnameVariants(hostname) {
const h = hostname.toLowerCase().replace(/\.$/, "");
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,
};