Files
AutistMask/tests/phishingDomains.test.js
T
sneak 6cf1965edd
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 06:33:58 +00:00

233 lines
8.5 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 60 seconds 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("a hostname ending in a dot is the same host", () => {
expect(isPhishingDomain(LISTED[1] + ".")).toBe(true);
expect(isPhishingDomain("wallet." + LISTED[1] + ".")).toBe(true);
expect(isPhishingDomain(CLEAN[0] + ".")).toBe(false);
});
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"]);
});
test("drops the trailing dot of a fully qualified hostname", () => {
expect(hostnameVariants("sub.evil.com.")).toEqual([
"sub.evil.com",
"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/,
);
});
});