Files
AutistMask/tests/phishingDomains.test.js
clawbot 2f17505525
Some checks failed
check / check (push) Has been cancelled
fix: drive background refresh and phishing update from alarms (closes #158)
The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
which destroyed both recurring jobs: the 60-second balance refresh and the
24-hour phishing blocklist refresh were setInterval schedules, so in
practice each ran only while the worker happened to be alive. The phishing
delta was persisted to localStorage, which does not exist in a service
worker, so on Chrome it was never persisted at all.

Both jobs now run off the extension alarms API in the new
src/shared/alarms.js: the browser holds the schedule and wakes the worker to
deliver it. The balance refresh is one minute and the phishing refresh is
1440 minutes, both whole minutes at or above the one-minute minimum, so
neither is silently clamped. Alarms are created only when missing or when the
existing one carries a different period, because creating one restarts its
period and the startup path runs on every wake — while an alarm left at an
older release's period would otherwise never be reconciled.

Each job's freshness guard is decoupled from its alarm period, or the period
would not be the cadence. A guard is measured from when the last run
finished, which is one run-duration after the alarm that started it, so a
guard timed to the period vetoes the very next tick and the real rate halves.
The two are handled differently because the guards differ in purpose: the
phishing cache TTL exists to keep the worker off the network on the wakes
between refreshes, so the scheduled tick bypasses it and fetches
unconditionally; the balance guard exists to skip work an open popup has
already done, so it must keep applying on the tick and is instead shortened
to half the alarm period — above the popup's 10-second refresh, below the
60-second period.

The phishing delta and the timestamps of the fetch that produced it now live
in extension storage, and updatePhishingList() reloads that record before
deciding whether a fetch is due. A revived worker therefore neither
re-fetches on every wake nor sleeps through an overdue update. A timestamp
read back from storage is discarded if it lies in the future: clock skew or a
restored profile backup would otherwise suppress updates until that time
arrived, permanently, now that the value outlives the worker.

Two timestamps are kept, not one. The 256 KiB cap still drops an oversized
delta together with its freshness claim, but the record of having contacted
the network at all is written regardless — as it is after a failed fetch —
and floors unscheduled retries at one hour. Without it, a list that is
persistently oversized or a fetch that persistently fails means a full
blocklist download on every worker wake, indefinitely.

The startup path (ensureRecurringAlarms plus the phishing list init) is
registered on onInstalled and onStartup as well as running at the top level
of the worker, and is idempotent. The concurrent callers on a fresh install
share one in-flight run rather than racing to create the same alarm, and a
failure is logged instead of becoming an unhandled rejection.

Firefox MV2 has a persistent background page where timers would have
survived, but both browsers are built from one bundle and both take the
alarm path, so there is a single code path; "alarms" is declared in both
manifests.

src/shared/ens.js keeps its localStorage cache and gains a comment recording
that it is popup-only, so it does not get pulled into the worker later.
2026-08-11 13:12:36 +00:00

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);
});
});