574 lines
21 KiB
JavaScript
574 lines
21 KiB
JavaScript
// Extension storage stub for the Node test environment. The module resolves
|
|
// the storage API on use, so this only has to exist before the first call.
|
|
// Values round-trip through JSON the way structured cloning would, so a test
|
|
// cannot pass by holding a live reference to the module's own array.
|
|
const storageStore = {};
|
|
global.chrome = {
|
|
storage: {
|
|
local: {
|
|
get: async (key) =>
|
|
Object.prototype.hasOwnProperty.call(storageStore, key)
|
|
? { [key]: JSON.parse(JSON.stringify(storageStore[key])) }
|
|
: {},
|
|
set: async (items) => {
|
|
for (const [key, value] of Object.entries(items)) {
|
|
storageStore[key] = JSON.parse(JSON.stringify(value));
|
|
}
|
|
},
|
|
remove: async (key) => {
|
|
delete storageStore[key];
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const {
|
|
isPhishingDomain,
|
|
loadConfig,
|
|
getBlocklistSize,
|
|
getDeltaSize,
|
|
hostnameVariants,
|
|
DELTA_STORAGE_KEY,
|
|
_reset,
|
|
_getVendoredBlacklistSize,
|
|
_getDeltaBlacklist,
|
|
} = require("../src/shared/phishingDomains");
|
|
|
|
function clearStorage() {
|
|
for (const key of Object.keys(storageStore)) {
|
|
delete storageStore[key];
|
|
}
|
|
}
|
|
|
|
// The MV3 service worker is torn down when idle and re-evaluated on the next
|
|
// event, which wipes every module-level variable. Re-requiring the module with
|
|
// the registry reset is exactly that: fresh in-memory state, same extension
|
|
// storage underneath.
|
|
function restartWorker() {
|
|
jest.resetModules();
|
|
return require("../src/shared/phishingDomains");
|
|
}
|
|
|
|
// Reset delta state before each test to avoid cross-test contamination.
|
|
// Note: vendored sets are immutable and always present.
|
|
beforeEach(() => {
|
|
_reset();
|
|
clearStorage();
|
|
});
|
|
|
|
describe("phishingDomains", () => {
|
|
describe("vendored blocklist", () => {
|
|
test("vendored blacklist is loaded from bundled JSON", () => {
|
|
// The vendored blocklist should have a large number of entries
|
|
expect(_getVendoredBlacklistSize()).toBeGreaterThan(100000);
|
|
});
|
|
|
|
test("detects domains from vendored blacklist", () => {
|
|
// These are well-known phishing domains in the vendored list
|
|
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
|
|
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
|
|
});
|
|
|
|
test("getBlocklistSize includes vendored entries", () => {
|
|
expect(getBlocklistSize()).toBeGreaterThan(100000);
|
|
});
|
|
});
|
|
|
|
describe("hostnameVariants", () => {
|
|
test("returns exact hostname plus parent domains", () => {
|
|
const variants = hostnameVariants("sub.evil.com");
|
|
expect(variants).toEqual(["sub.evil.com", "evil.com"]);
|
|
});
|
|
|
|
test("returns just the hostname for a bare domain", () => {
|
|
const variants = hostnameVariants("example.com");
|
|
expect(variants).toEqual(["example.com"]);
|
|
});
|
|
|
|
test("handles deep subdomain chains", () => {
|
|
const variants = hostnameVariants("a.b.c.d.com");
|
|
expect(variants).toEqual([
|
|
"a.b.c.d.com",
|
|
"b.c.d.com",
|
|
"c.d.com",
|
|
"d.com",
|
|
]);
|
|
});
|
|
|
|
test("lowercases hostnames", () => {
|
|
const variants = hostnameVariants("Evil.COM");
|
|
expect(variants).toEqual(["evil.com"]);
|
|
});
|
|
});
|
|
|
|
describe("delta computation via loadConfig", () => {
|
|
test("loadConfig computes delta of new entries not in vendored list", () => {
|
|
loadConfig({
|
|
blacklist: [
|
|
"brand-new-scam-site-xyz123.com",
|
|
"hopprotocol.pro", // already in vendored
|
|
],
|
|
});
|
|
// Only the new domain should be in the delta
|
|
expect(
|
|
_getDeltaBlacklist().has("brand-new-scam-site-xyz123.com"),
|
|
).toBe(true);
|
|
expect(_getDeltaBlacklist().has("hopprotocol.pro")).toBe(false);
|
|
expect(getDeltaSize()).toBe(1);
|
|
});
|
|
|
|
test("re-loading config replaces previous delta", () => {
|
|
loadConfig({
|
|
blacklist: ["first-scam-xyz.com"],
|
|
});
|
|
expect(isPhishingDomain("first-scam-xyz.com")).toBe(true);
|
|
|
|
loadConfig({
|
|
blacklist: ["second-scam-xyz.com"],
|
|
});
|
|
expect(isPhishingDomain("first-scam-xyz.com")).toBe(false);
|
|
expect(isPhishingDomain("second-scam-xyz.com")).toBe(true);
|
|
});
|
|
|
|
test("getBlocklistSize includes both vendored and delta", () => {
|
|
const baseSize = getBlocklistSize();
|
|
loadConfig({
|
|
blacklist: ["delta-only-scam-xyz.com"],
|
|
});
|
|
expect(getBlocklistSize()).toBe(baseSize + 1);
|
|
});
|
|
});
|
|
|
|
describe("isPhishingDomain with delta + vendored", () => {
|
|
test("detects domain from delta blacklist", () => {
|
|
loadConfig({
|
|
blacklist: ["fresh-scam-xyz.com"],
|
|
});
|
|
expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true);
|
|
});
|
|
|
|
test("detects domain from vendored blacklist", () => {
|
|
// No delta loaded — vendored still works
|
|
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
|
|
});
|
|
|
|
test("returns false for clean domains", () => {
|
|
expect(isPhishingDomain("etherscan.io")).toBe(false);
|
|
expect(isPhishingDomain("example.com")).toBe(false);
|
|
});
|
|
|
|
test("detects subdomain of blacklisted domain (vendored)", () => {
|
|
expect(isPhishingDomain("app.hopprotocol.pro")).toBe(true);
|
|
});
|
|
|
|
test("detects subdomain of blacklisted domain (delta)", () => {
|
|
loadConfig({
|
|
blacklist: ["delta-phish-xyz.com"],
|
|
});
|
|
expect(isPhishingDomain("sub.delta-phish-xyz.com")).toBe(true);
|
|
});
|
|
|
|
test("case-insensitive matching", () => {
|
|
loadConfig({
|
|
blacklist: ["Delta-Scam-XYZ.COM"],
|
|
});
|
|
expect(isPhishingDomain("delta-scam-xyz.com")).toBe(true);
|
|
expect(isPhishingDomain("DELTA-SCAM-XYZ.COM")).toBe(true);
|
|
});
|
|
|
|
test("returns false for empty/null hostname", () => {
|
|
expect(isPhishingDomain("")).toBe(false);
|
|
expect(isPhishingDomain(null)).toBe(false);
|
|
});
|
|
|
|
test("handles config with no blacklist key", () => {
|
|
loadConfig({});
|
|
expect(getDeltaSize()).toBe(0);
|
|
// Vendored list still works
|
|
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("extension storage persistence", () => {
|
|
test("delta is persisted to extension storage, not localStorage", async () => {
|
|
await loadConfig({
|
|
blacklist: ["persisted-scam-xyz.com"],
|
|
});
|
|
const stored = storageStore[DELTA_STORAGE_KEY];
|
|
expect(stored).toBeDefined();
|
|
expect(stored.blacklist).toContain("persisted-scam-xyz.com");
|
|
});
|
|
|
|
test("the fetch timestamp is persisted alongside the delta", async () => {
|
|
const before = Date.now();
|
|
await loadConfig({ blacklist: ["timestamped-scam-xyz.com"] });
|
|
const stored = storageStore[DELTA_STORAGE_KEY];
|
|
expect(typeof stored.lastFetchTime).toBe("number");
|
|
expect(stored.lastFetchTime).toBeGreaterThanOrEqual(before);
|
|
});
|
|
|
|
test("an oversized delta is dropped entirely, timestamp included", async () => {
|
|
// A record above the 256 KiB cap is not worth keeping; the
|
|
// timestamp goes with it so the next start re-fetches rather than
|
|
// claiming freshness for a delta that was never stored.
|
|
const huge = [];
|
|
for (let i = 0; i < 20000; i++) {
|
|
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
|
|
}
|
|
await loadConfig({ blacklist: huge });
|
|
expect(storageStore[DELTA_STORAGE_KEY]).toBeUndefined();
|
|
});
|
|
|
|
test("delta is cleared on _reset", () => {
|
|
loadConfig({
|
|
blacklist: ["temp-scam-xyz.com"],
|
|
});
|
|
expect(getDeltaSize()).toBe(1);
|
|
_reset();
|
|
expect(getDeltaSize()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("real-world blocklist patterns", () => {
|
|
test("detects known phishing domains from vendored list", () => {
|
|
expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true);
|
|
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
|
|
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
|
|
});
|
|
|
|
test("does not flag legitimate domains", () => {
|
|
expect(isPhishingDomain("opensea.io")).toBe(false);
|
|
expect(isPhishingDomain("etherscan.io")).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("phishing list across a service worker restart", () => {
|
|
beforeEach(() => {
|
|
clearStorage();
|
|
jest.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete global.fetch;
|
|
});
|
|
|
|
test("a revived worker restores the persisted delta without re-fetching", async () => {
|
|
const first = require("../src/shared/phishingDomains");
|
|
await first.loadConfig({ blacklist: ["restart-scam-xyz.com"] });
|
|
|
|
const revived = restartWorker();
|
|
// Nothing in memory yet — this is a brand new module instance.
|
|
expect(revived.getDeltaSize()).toBe(0);
|
|
|
|
global.fetch = jest.fn();
|
|
await revived.initPhishingList();
|
|
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
expect(revived.getDeltaSize()).toBe(1);
|
|
expect(revived.isPhishingDomain("restart-scam-xyz.com")).toBe(true);
|
|
});
|
|
|
|
test("repeated wakes inside the cache window never re-fetch", async () => {
|
|
const first = require("../src/shared/phishingDomains");
|
|
await first.loadConfig({ blacklist: ["no-storm-scam-xyz.com"] });
|
|
|
|
global.fetch = jest.fn();
|
|
for (let i = 0; i < 5; i++) {
|
|
const revived = restartWorker();
|
|
await revived.initPhishingList();
|
|
}
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("a persisted timestamp older than the TTL causes a fetch on startup", async () => {
|
|
const first = require("../src/shared/phishingDomains");
|
|
await first.loadConfig({ blacklist: ["stale-scam-xyz.com"] });
|
|
|
|
// Age the persisted record past the 24-hour TTL.
|
|
storageStore[first.DELTA_STORAGE_KEY].lastFetchTime =
|
|
Date.now() - first.CACHE_TTL_MS - 1000;
|
|
|
|
const revived = restartWorker();
|
|
global.fetch = jest.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({ blacklist: ["refreshed-scam-xyz.com"] }),
|
|
}));
|
|
await revived.initPhishingList();
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
expect(revived.isPhishingDomain("refreshed-scam-xyz.com")).toBe(true);
|
|
expect(revived.isPhishingDomain("stale-scam-xyz.com")).toBe(false);
|
|
});
|
|
|
|
test("a first start with nothing persisted fetches immediately", async () => {
|
|
const fresh = restartWorker();
|
|
global.fetch = jest.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({ blacklist: ["first-run-scam-xyz.com"] }),
|
|
}));
|
|
await fresh.initPhishingList();
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
expect(fresh.isPhishingDomain("first-run-scam-xyz.com")).toBe(true);
|
|
});
|
|
|
|
test("updatePhishingList honours the persisted timestamp on its own", async () => {
|
|
// The startup path calls updatePhishingList() directly, so it must
|
|
// load persisted state itself rather than relying on anything else
|
|
// having finished first.
|
|
const first = require("../src/shared/phishingDomains");
|
|
await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });
|
|
|
|
const revived = restartWorker();
|
|
global.fetch = jest.fn();
|
|
await revived.updatePhishingList();
|
|
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// The alarm period alone must set the cadence. lastFetchTime is stamped when
|
|
// the fetch completes, so it lands one fetch latency after the alarm that
|
|
// caused it; a freshness guard timed to the alarm period therefore vetoes
|
|
// every scheduled tick and halves the real refresh rate. These tests measure
|
|
// the interval between fetches that actually happened.
|
|
describe("phishing refresh steady-state cadence", () => {
|
|
const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms");
|
|
const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
|
|
|
let clockSpy;
|
|
let now;
|
|
|
|
beforeEach(() => {
|
|
clearStorage();
|
|
jest.resetModules();
|
|
now = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clockSpy.mockRestore();
|
|
delete global.fetch;
|
|
});
|
|
|
|
function fetchStub(latencyMs, seen) {
|
|
return jest.fn(async () => {
|
|
seen.push(now);
|
|
// A network fetch takes time, and lastFetchTime is stamped after
|
|
// it, not when the alarm fired.
|
|
now += latencyMs;
|
|
return { ok: true, json: async () => ({ blacklist: [] }) };
|
|
});
|
|
}
|
|
|
|
test("ten alarm ticks produce ten fetches, one per period", async () => {
|
|
const fetchedAt = [];
|
|
global.fetch = fetchStub(5000, fetchedAt);
|
|
|
|
const startup = require("../src/shared/phishingDomains");
|
|
const T0 = now;
|
|
await startup.initPhishingList();
|
|
expect(fetchedAt).toEqual([T0]);
|
|
|
|
const TICKS = 10;
|
|
let tickAt = T0 + PERIOD_MS;
|
|
for (let i = 0; i < TICKS; i++) {
|
|
now = tickAt;
|
|
tickAt += PERIOD_MS;
|
|
// The browser wakes a terminated worker to deliver the alarm, so
|
|
// every tick starts from cold memory and the persisted record.
|
|
const revived = restartWorker();
|
|
await revived.refreshPhishingListOnSchedule();
|
|
}
|
|
|
|
expect(fetchedAt).toHaveLength(TICKS + 1);
|
|
const intervals = fetchedAt.slice(1).map((t, i) => t - fetchedAt[i]);
|
|
expect(intervals).toEqual(new Array(TICKS).fill(PERIOD_MS));
|
|
});
|
|
|
|
test("the scheduled tick fetches whatever the last fetch's latency was", async () => {
|
|
// The alarm fires one period after the previous alarm, which is
|
|
// `latency` short of one period since the fetch it caused completed.
|
|
for (const latency of [200, 1000, 5000]) {
|
|
clearStorage();
|
|
jest.resetModules();
|
|
storageStore[DELTA_STORAGE_KEY] = {
|
|
blacklist: [],
|
|
lastFetchTime: now - PERIOD_MS + latency,
|
|
lastAttemptTime: now - PERIOD_MS,
|
|
};
|
|
const mod = require("../src/shared/phishingDomains");
|
|
const fetchedAt = [];
|
|
global.fetch = fetchStub(latency, fetchedAt);
|
|
|
|
await mod.refreshPhishingListOnSchedule();
|
|
expect(fetchedAt).toHaveLength(1);
|
|
}
|
|
});
|
|
|
|
test("a worker wake inside the cache window still does not fetch", async () => {
|
|
// The TTL is not removed, only taken off the scheduled path. Chrome
|
|
// revives the worker every ~30 seconds and every revival runs the
|
|
// startup path, so the TTL still has to keep that off the network.
|
|
storageStore[DELTA_STORAGE_KEY] = {
|
|
blacklist: [],
|
|
lastFetchTime: now - PERIOD_MS + 5000,
|
|
lastAttemptTime: now - PERIOD_MS,
|
|
};
|
|
const mod = require("../src/shared/phishingDomains");
|
|
global.fetch = jest.fn();
|
|
|
|
await mod.initPhishingList();
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("phishing list timestamps that cannot be trusted", () => {
|
|
let clockSpy;
|
|
let now;
|
|
|
|
beforeEach(() => {
|
|
clearStorage();
|
|
jest.resetModules();
|
|
now = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clockSpy.mockRestore();
|
|
delete global.fetch;
|
|
});
|
|
|
|
function okFetch() {
|
|
return jest.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({ blacklist: ["recovered-scam-xyz.com"] }),
|
|
}));
|
|
}
|
|
|
|
// jest.resetModules() clears the call record of a jest.fn, and simulating
|
|
// a worker restart is exactly that call. Anything counted across restarts
|
|
// has to be counted outside the mock.
|
|
function countingFetch(counter, response) {
|
|
return async () => {
|
|
counter.calls++;
|
|
return response();
|
|
};
|
|
}
|
|
|
|
test("a lastFetchTime in the future is discarded rather than trusted", async () => {
|
|
// Clock skew or a restored profile backup writes one. Every guard
|
|
// measures `Date.now() - stamp` and only tests the lower bound, so a
|
|
// stamp a year ahead would suppress updates for a year, and now that
|
|
// the value is persisted it would outlive every worker.
|
|
storageStore[DELTA_STORAGE_KEY] = {
|
|
blacklist: ["poisoned-scam-xyz.com"],
|
|
lastFetchTime: now + 365 * 24 * 60 * 60 * 1000,
|
|
lastAttemptTime: 0,
|
|
};
|
|
const mod = require("../src/shared/phishingDomains");
|
|
global.fetch = okFetch();
|
|
|
|
await mod.initPhishingList();
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
expect(mod.isPhishingDomain("recovered-scam-xyz.com")).toBe(true);
|
|
// And the record it leaves behind is sane, so recovery is permanent.
|
|
expect(
|
|
storageStore[DELTA_STORAGE_KEY].lastFetchTime,
|
|
).toBeLessThanOrEqual(now);
|
|
});
|
|
|
|
test("a lastAttemptTime in the future does not suppress the retry", async () => {
|
|
storageStore[DELTA_STORAGE_KEY] = {
|
|
lastAttemptTime: now + 365 * 24 * 60 * 60 * 1000,
|
|
};
|
|
const mod = require("../src/shared/phishingDomains");
|
|
global.fetch = okFetch();
|
|
|
|
await mod.initPhishingList();
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("an oversized delta does not re-download on every worker wake", async () => {
|
|
// The delta and its freshness claim are both dropped, which is right,
|
|
// but nothing then says a fetch just happened. Chrome cycles the
|
|
// worker roughly every 30 seconds idle, so without the attempt stamp
|
|
// this is a full blocklist download per wake, forever.
|
|
const huge = [];
|
|
for (let i = 0; i < 20000; i++) {
|
|
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
|
|
}
|
|
const counter = { calls: 0 };
|
|
global.fetch = countingFetch(counter, () => ({
|
|
ok: true,
|
|
json: async () => ({ blacklist: huge }),
|
|
}));
|
|
|
|
for (let wake = 0; wake < 4; wake++) {
|
|
const revived = restartWorker();
|
|
await revived.initPhishingList();
|
|
now += 30 * 1000; // idle timeout, worker torn down and revived
|
|
}
|
|
|
|
expect(counter.calls).toBe(1);
|
|
expect(storageStore[DELTA_STORAGE_KEY].blacklist).toBeUndefined();
|
|
expect(typeof storageStore[DELTA_STORAGE_KEY].lastAttemptTime).toBe(
|
|
"number",
|
|
);
|
|
});
|
|
|
|
test("a failing fetch is not retried on every worker wake either", async () => {
|
|
const counter = { calls: 0 };
|
|
global.fetch = countingFetch(counter, () => ({
|
|
ok: false,
|
|
status: 503,
|
|
}));
|
|
|
|
for (let wake = 0; wake < 4; wake++) {
|
|
const revived = restartWorker();
|
|
await revived.initPhishingList();
|
|
now += 30 * 1000;
|
|
}
|
|
|
|
expect(counter.calls).toBe(1);
|
|
});
|
|
|
|
test("the retry floor expires, so a failure is not permanent", async () => {
|
|
const {
|
|
MIN_FETCH_ATTEMPT_INTERVAL_MS,
|
|
} = require("../src/shared/phishingDomains");
|
|
const counter = { calls: 0 };
|
|
global.fetch = countingFetch(counter, () => ({
|
|
ok: false,
|
|
status: 503,
|
|
}));
|
|
|
|
await restartWorker().initPhishingList();
|
|
expect(counter.calls).toBe(1);
|
|
|
|
// Still inside the floor: no retry.
|
|
now += MIN_FETCH_ATTEMPT_INTERVAL_MS - 1000;
|
|
await restartWorker().initPhishingList();
|
|
expect(counter.calls).toBe(1);
|
|
|
|
// Past it: the extension goes back to the network.
|
|
now += 2000;
|
|
await restartWorker().initPhishingList();
|
|
expect(counter.calls).toBe(2);
|
|
});
|
|
|
|
test("the scheduled tick ignores the retry floor", async () => {
|
|
// The alarm period is far above the floor, but the floor exists to
|
|
// throttle wakes, not the schedule.
|
|
storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 };
|
|
const mod = require("../src/shared/phishingDomains");
|
|
global.fetch = okFetch();
|
|
|
|
await mod.refreshPhishingListOnSchedule();
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|