feat: vendor and censor the phishing blocklist at build time (closes #219)
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 three literals shipped code cannot avoid — two provider-shim identifiers in src/content/inpage.js and one ERC-20's on-chain name in src/shared/tokenList.js. Each is permitted only at the path that carries it, and at the emitted paths that path is bundled into, so a literal appearing anywhere else fails like any other occurrence. 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.
This commit is contained in:
@@ -80,17 +80,12 @@ describe("alarms module", () => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
test("ensureRecurringAlarms schedules both recurring jobs", async () => {
|
||||
test("ensureRecurringAlarms schedules the recurring job", async () => {
|
||||
const created = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(created).toEqual({ balance: true, phishing: true });
|
||||
expect(created).toEqual({ balance: true, cleared: [] });
|
||||
|
||||
const names = alarmsStub.created.map((c) => c.name).sort();
|
||||
expect(names).toEqual(
|
||||
[
|
||||
alarmsMod.BALANCE_REFRESH_ALARM,
|
||||
alarmsMod.PHISHING_REFRESH_ALARM,
|
||||
].sort(),
|
||||
);
|
||||
const names = alarmsStub.created.map((c) => c.name);
|
||||
expect(names).toEqual([alarmsMod.BALANCE_REFRESH_ALARM]);
|
||||
});
|
||||
|
||||
test("the balance refresh keeps its 60-second cadence", async () => {
|
||||
@@ -99,12 +94,35 @@ describe("alarms module", () => {
|
||||
expect(balance.periodInMinutes).toBe(1);
|
||||
});
|
||||
|
||||
test("the phishing refresh keeps its 24-hour cadence", async () => {
|
||||
test("a retired job's alarm is cleared, not left running", async () => {
|
||||
// The browser holds an alarm until something clears it. Deleting the
|
||||
// job from the code is not enough: on every install that ever ran the
|
||||
// version which created it, the alarm goes on waking the service
|
||||
// worker on its old schedule with nothing to deliver it to.
|
||||
for (const name of alarmsMod.OBSOLETE_ALARMS) {
|
||||
alarmsStub.create(name, { periodInMinutes: 24 * 60 });
|
||||
}
|
||||
expect(alarmsMod.OBSOLETE_ALARMS.length).toBeGreaterThan(0);
|
||||
|
||||
const result = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(result.cleared).toEqual(alarmsMod.OBSOLETE_ALARMS);
|
||||
for (const name of alarmsMod.OBSOLETE_ALARMS) {
|
||||
expect(alarmsStub.alarms.get(name)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("clearing a retired alarm is not re-reported once it is gone", async () => {
|
||||
await alarmsMod.ensureRecurringAlarms();
|
||||
const phishing = alarmsStub.alarms.get(
|
||||
alarmsMod.PHISHING_REFRESH_ALARM,
|
||||
const again = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(again.cleared).toEqual([]);
|
||||
});
|
||||
|
||||
test("no retired name is also a live one", async () => {
|
||||
// A name in both lists would be created and then cleared on every
|
||||
// start, so the job it schedules would never fire.
|
||||
expect(alarmsMod.OBSOLETE_ALARMS).not.toContain(
|
||||
alarmsMod.BALANCE_REFRESH_ALARM,
|
||||
);
|
||||
expect(phishing.periodInMinutes).toBe(24 * 60);
|
||||
});
|
||||
|
||||
test("no period is below the browser-enforced minimum", async () => {
|
||||
@@ -122,14 +140,14 @@ describe("alarms module", () => {
|
||||
|
||||
test("a revived worker does not reset an existing alarm's schedule", async () => {
|
||||
await alarmsMod.ensureRecurringAlarms();
|
||||
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
|
||||
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Every wake re-runs the startup path. Re-creating an alarm restarts
|
||||
// its period, so a busy extension would push the next fire out
|
||||
// forever and the job would never run.
|
||||
const again = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(again).toEqual({ balance: false, phishing: false });
|
||||
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
|
||||
expect(again).toEqual({ balance: false, cleared: [] });
|
||||
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a missing alarm is re-created on the next start", async () => {
|
||||
@@ -137,7 +155,7 @@ describe("alarms module", () => {
|
||||
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
|
||||
|
||||
const again = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(again).toEqual({ balance: true, phishing: false });
|
||||
expect(again).toEqual({ balance: true, cleared: [] });
|
||||
expect(
|
||||
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
|
||||
).toBeDefined();
|
||||
@@ -147,17 +165,17 @@ describe("alarms module", () => {
|
||||
// An install carries its alarms across an extension update, so a
|
||||
// period changed in a new release only ever reaches users if the
|
||||
// stale one is reconciled.
|
||||
alarmsStub.create(alarmsMod.PHISHING_REFRESH_ALARM, {
|
||||
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
|
||||
periodInMinutes: 7 * 24 * 60,
|
||||
});
|
||||
alarmsStub.create.mockClear();
|
||||
|
||||
const created = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(created.phishing).toBe(true);
|
||||
expect(created.balance).toBe(true);
|
||||
expect(
|
||||
alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM)
|
||||
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM)
|
||||
.periodInMinutes,
|
||||
).toBe(alarmsMod.PHISHING_REFRESH_PERIOD_MINUTES);
|
||||
).toBe(alarmsMod.BALANCE_REFRESH_PERIOD_MINUTES);
|
||||
});
|
||||
|
||||
test("reconciling a period settles instead of re-creating forever", async () => {
|
||||
@@ -168,31 +186,31 @@ describe("alarms module", () => {
|
||||
alarmsStub.create.mockClear();
|
||||
|
||||
const again = await alarmsMod.ensureRecurringAlarms();
|
||||
expect(again).toEqual({ balance: false, phishing: false });
|
||||
expect(again).toEqual({ balance: false, cleared: [] });
|
||||
expect(alarmsStub.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handlers are dispatched by alarm name from one listener", () => {
|
||||
const balance = jest.fn();
|
||||
const phishing = jest.fn();
|
||||
const other = jest.fn();
|
||||
expect(
|
||||
alarmsMod.registerAlarmHandlers({
|
||||
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
|
||||
[alarmsMod.PHISHING_REFRESH_ALARM]: phishing,
|
||||
"autistmask-some-other-job": other,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(alarmsStub.listenerCount()).toBe(1);
|
||||
|
||||
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
|
||||
expect(balance).toHaveBeenCalledTimes(1);
|
||||
expect(phishing).not.toHaveBeenCalled();
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
|
||||
alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM);
|
||||
expect(phishing).toHaveBeenCalledTimes(1);
|
||||
alarmsStub.fire("autistmask-some-other-job");
|
||||
expect(other).toHaveBeenCalledTimes(1);
|
||||
|
||||
alarmsStub.fire("some-other-extension-alarm");
|
||||
alarmsStub.fire("an-alarm-with-no-handler");
|
||||
expect(balance).toHaveBeenCalledTimes(1);
|
||||
expect(phishing).toHaveBeenCalledTimes(1);
|
||||
expect(other).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
|
||||
@@ -205,8 +223,8 @@ describe("alarms module", () => {
|
||||
try {
|
||||
const mod = require("../src/shared/alarms");
|
||||
const created = await mod.ensureRecurringAlarms();
|
||||
expect(created).toEqual({ balance: true, phishing: true });
|
||||
expect(firefoxAlarms.created).toHaveLength(2);
|
||||
expect(created).toEqual({ balance: true, cleared: [] });
|
||||
expect(firefoxAlarms.created).toHaveLength(1);
|
||||
// The Chrome stub must not have been touched.
|
||||
expect(alarmsStub.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
@@ -220,7 +238,7 @@ describe("alarms module", () => {
|
||||
const mod = require("../src/shared/alarms");
|
||||
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
|
||||
balance: false,
|
||||
phishing: false,
|
||||
cleared: [],
|
||||
});
|
||||
expect(mod.registerAlarmHandlers({})).toBe(false);
|
||||
});
|
||||
@@ -274,9 +292,12 @@ function loadBackground(initialStore = {}) {
|
||||
tabs: { query: jest.fn(), sendMessage: jest.fn() },
|
||||
action: { setPopup: jest.fn() },
|
||||
};
|
||||
// Present so that a startup path which went to the network would be
|
||||
// recorded rather than throwing, which is what makes "no request was made"
|
||||
// an observation instead of an assumption.
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ blacklist: [] }),
|
||||
json: async () => ({}),
|
||||
}));
|
||||
jest.resetModules();
|
||||
require("../src/background/index");
|
||||
@@ -318,17 +339,21 @@ describe("background worker scheduling", () => {
|
||||
// Let the startup path's promises settle.
|
||||
await settle();
|
||||
|
||||
const names = alarmsStub.created.map((c) => c.name).sort();
|
||||
const {
|
||||
BALANCE_REFRESH_ALARM,
|
||||
PHISHING_REFRESH_ALARM,
|
||||
} = require("../src/shared/alarms");
|
||||
expect(names).toEqual(
|
||||
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
|
||||
);
|
||||
const names = alarmsStub.created.map((c) => c.name);
|
||||
const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
|
||||
expect(names).toEqual([BALANCE_REFRESH_ALARM]);
|
||||
expect(mockSetIntervalCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("startup contacts nothing", async () => {
|
||||
// The phishing blocklist is vendored at build time and there is no
|
||||
// other startup fetch, so a worker coming up asks nobody anything.
|
||||
// Every wake used to be a candidate for a blocklist download.
|
||||
loadBackground();
|
||||
await settle();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("an onAlarm listener is installed on startup", async () => {
|
||||
alarmsStub = loadBackground().alarmsStub;
|
||||
await settle();
|
||||
@@ -348,7 +373,7 @@ describe("background worker scheduling", () => {
|
||||
alarmsStub.created.length = 0;
|
||||
loaded.listeners.onStartup[0]();
|
||||
await settle();
|
||||
expect(alarmsStub.created).toHaveLength(2);
|
||||
expect(alarmsStub.created).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("the install-time listener and the top-level call share one run", async () => {
|
||||
@@ -360,13 +385,10 @@ describe("background worker scheduling", () => {
|
||||
loaded.listeners.onInstalled[0]();
|
||||
await settle();
|
||||
|
||||
expect(alarmsStub.created).toHaveLength(2);
|
||||
expect(alarmsStub.created.map((c) => c.name).sort()).toEqual(
|
||||
[
|
||||
"autistmask-balance-refresh",
|
||||
"autistmask-phishing-refresh",
|
||||
].sort(),
|
||||
);
|
||||
expect(alarmsStub.created).toHaveLength(1);
|
||||
expect(alarmsStub.created.map((c) => c.name)).toEqual([
|
||||
"autistmask-balance-refresh",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user