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 two literals shipped code cannot avoid. 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:
@@ -27,14 +27,9 @@ const {
|
||||
TX_STAGE_NONCE,
|
||||
} = require("../shared/approvalVerify");
|
||||
const { prepareApprovalTx } = require("../shared/approvalTx");
|
||||
const {
|
||||
isPhishingDomain,
|
||||
refreshPhishingListOnSchedule,
|
||||
initPhishingList,
|
||||
} = require("../shared/phishingDomains");
|
||||
const { isPhishingDomain } = require("../shared/phishingDomains");
|
||||
const {
|
||||
BALANCE_REFRESH_ALARM,
|
||||
PHISHING_REFRESH_ALARM,
|
||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||
ensureRecurringAlarms,
|
||||
registerAlarmHandlers,
|
||||
@@ -1003,26 +998,20 @@ async function backgroundRefresh() {
|
||||
await saveState();
|
||||
}
|
||||
|
||||
// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is
|
||||
// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
|
||||
// a service worker that the browser terminates after about 30 seconds idle,
|
||||
// so a setInterval would only ever survive until the first idle period and
|
||||
// module-level state does not outlive it. Alarms are held by the browser and
|
||||
// wake the worker to deliver them.
|
||||
registerAlarmHandlers({
|
||||
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
|
||||
// The scheduled refresh, which restores persisted state on a freshly
|
||||
// revived worker and then fetches unconditionally. The freshness guards
|
||||
// belong to the startup path; applying them here would make the tick skip
|
||||
// itself.
|
||||
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
|
||||
});
|
||||
|
||||
// Everything the background context needs re-established on start. This runs
|
||||
// on a fresh install, on browser startup, and on every revival of a
|
||||
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
|
||||
// creates alarms that are missing or carrying a stale period, and
|
||||
// initPhishingList() fetches only when the persisted timestamps say the list
|
||||
// is stale.
|
||||
// creates alarms that are missing or carrying a stale period, and only clears
|
||||
// retired ones that are still registered.
|
||||
//
|
||||
// On a fresh install the top-level call and the onInstalled listener both run,
|
||||
// close enough together that both could see an alarm missing and create it.
|
||||
@@ -1033,10 +1022,7 @@ let backgroundJobsRun = null;
|
||||
|
||||
function startBackgroundJobs() {
|
||||
if (backgroundJobsRun) return backgroundJobsRun;
|
||||
backgroundJobsRun = Promise.all([
|
||||
ensureRecurringAlarms(),
|
||||
initPhishingList(),
|
||||
])
|
||||
backgroundJobsRun = ensureRecurringAlarms()
|
||||
.catch((err) => {
|
||||
// An alarm that failed to schedule means a recurring job silently
|
||||
// never runs again; it must not be an unhandled rejection.
|
||||
|
||||
@@ -179,7 +179,8 @@
|
||||
return this;
|
||||
},
|
||||
|
||||
// Some dApps (wagmi) check this to confirm MetaMask-like behavior
|
||||
// Some dApps (wagmi) probe this object to decide whether the provider
|
||||
// supports the de-facto standard extras. The name is theirs, not ours.
|
||||
_metamask: {
|
||||
isUnlocked() {
|
||||
return Promise.resolve(provider.selectedAddress !== null);
|
||||
|
||||
@@ -17,16 +17,26 @@
|
||||
// run finished and the alarm fires one run-duration earlier than that. Every
|
||||
// guard must therefore either be strictly shorter than the period it gates or
|
||||
// be bypassed on the scheduled tick — see backgroundRefresh() in
|
||||
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
|
||||
// src/background/index.js.
|
||||
|
||||
const { alarmsApi } = require("./browserApi");
|
||||
|
||||
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
|
||||
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
|
||||
|
||||
// Alarms this extension used to create and no longer has a handler for. A
|
||||
// browser keeps an alarm until something clears it, so a job that is deleted
|
||||
// from the code goes on waking the service worker on its old schedule forever,
|
||||
// on every install that ever ran the version which created it. Removing the job
|
||||
// means removing the alarm, so retired names are listed here and cleared on
|
||||
// every start until the installs that carry them are long gone.
|
||||
const OBSOLETE_ALARMS = [
|
||||
// The 24-hour phishing blocklist refresh, retired when the runtime fetch
|
||||
// was removed and the list became purely build-time vendored.
|
||||
"autistmask-phishing-refresh",
|
||||
];
|
||||
|
||||
const MIN_ALARM_PERIOD_MINUTES = 1;
|
||||
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
|
||||
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
|
||||
|
||||
// alarmsApi() resolves on use rather than at module load: the worker is torn
|
||||
// down and re-evaluated repeatedly, and tests install a stub after requiring
|
||||
@@ -65,22 +75,34 @@ async function ensureAlarm(name, periodInMinutes) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure both recurring background jobs are scheduled. Safe to call on every
|
||||
* worker start, on onInstalled and on onStartup.
|
||||
* Clear every alarm this extension no longer handles.
|
||||
*
|
||||
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this
|
||||
* call had to create.
|
||||
* @returns {Promise<string[]>} the retired alarms this call actually cleared.
|
||||
*/
|
||||
async function clearObsoleteAlarms() {
|
||||
const api = alarmsApi();
|
||||
if (!api || !api.clear) return [];
|
||||
const cleared = [];
|
||||
for (const name of OBSOLETE_ALARMS) {
|
||||
if (await api.clear(name)) cleared.push(name);
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the recurring background jobs are scheduled, and that retired ones are
|
||||
* not. Safe to call on every worker start, on onInstalled and on onStartup.
|
||||
*
|
||||
* @returns {Promise<{balance: boolean, cleared: string[]}>} which alarms this
|
||||
* call had to create, and which retired ones it removed.
|
||||
*/
|
||||
async function ensureRecurringAlarms() {
|
||||
const balance = await ensureAlarm(
|
||||
BALANCE_REFRESH_ALARM,
|
||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||
);
|
||||
const phishing = await ensureAlarm(
|
||||
PHISHING_REFRESH_ALARM,
|
||||
PHISHING_REFRESH_PERIOD_MINUTES,
|
||||
);
|
||||
return { balance, phishing };
|
||||
const cleared = await clearObsoleteAlarms();
|
||||
return { balance, cleared };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,10 +124,10 @@ function registerAlarmHandlers(handlers) {
|
||||
|
||||
module.exports = {
|
||||
BALANCE_REFRESH_ALARM,
|
||||
PHISHING_REFRESH_ALARM,
|
||||
OBSOLETE_ALARMS,
|
||||
MIN_ALARM_PERIOD_MINUTES,
|
||||
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||
PHISHING_REFRESH_PERIOD_MINUTES,
|
||||
clearObsoleteAlarms,
|
||||
ensureAlarm,
|
||||
ensureRecurringAlarms,
|
||||
registerAlarmHandlers,
|
||||
|
||||
41
src/shared/domainHash.js
Normal file
41
src/shared/domainHash.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// The one definition of how a domain becomes a blocklist entry.
|
||||
//
|
||||
// The vendored phishing blocklist ships digests, not domain names: see
|
||||
// phishingDomains.js for why, and script/vendor-blocklist for how the artifact
|
||||
// is produced. Both sides have to agree exactly — a mismatch would silently
|
||||
// match nothing, which is a blocklist that quietly protects no one — so the
|
||||
// rule lives here and is required by both rather than written down twice.
|
||||
//
|
||||
// sha256 truncated to 64 bits. Truncation is what keeps the artifact small
|
||||
// enough to bundle (16 hex characters per entry rather than 64), and 64 bits is
|
||||
// far past what this has to withstand: over ~10^5 entries the chance that any
|
||||
// hostname a user visits collides with an entry it is not is about 10^-14 per
|
||||
// lookup, and a deliberate collision buys an attacker a false phishing warning
|
||||
// on a site they do not control, not a missed one. For scale, Safe Browsing
|
||||
// distributes 32-bit prefixes and resolves the rest against a server; this is
|
||||
// 32 bits more, with no server involved.
|
||||
|
||||
const { sha256, toUtf8Bytes } = require("ethers");
|
||||
|
||||
const HASH_ALGORITHM = "sha256";
|
||||
const HASH_HEX_CHARS = 16;
|
||||
|
||||
/**
|
||||
* The blocklist entry for a domain: lowercased, hashed, truncated.
|
||||
*
|
||||
* @param {string} domain
|
||||
* @returns {string} HASH_HEX_CHARS lowercase hex characters, no 0x prefix.
|
||||
*/
|
||||
function hashDomain(domain) {
|
||||
// ethers returns "0x" + 64 hex characters.
|
||||
return sha256(toUtf8Bytes(domain.toLowerCase())).slice(
|
||||
2,
|
||||
2 + HASH_HEX_CHARS,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HASH_ALGORITHM,
|
||||
HASH_HEX_CHARS,
|
||||
hashDomain,
|
||||
};
|
||||
@@ -4,8 +4,7 @@
|
||||
//
|
||||
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
|
||||
// so this module must not be pulled into src/background/. Anything the
|
||||
// background context needs to cache goes in extension storage instead (see
|
||||
// shared/phishingDomains.js).
|
||||
// background context needs to cache goes in extension storage instead.
|
||||
|
||||
const { getProvider } = require("./balances");
|
||||
const { log } = require("./log");
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,158 +1,109 @@
|
||||
// Domain-based phishing detection using a vendored blocklist with delta updates.
|
||||
// Domain-based phishing detection against a blocklist vendored at build time.
|
||||
//
|
||||
// A community-maintained phishing domain blocklist is vendored in
|
||||
// phishingBlocklist.json and bundled at build time. At runtime, we fetch
|
||||
// the live list periodically and keep only the delta (new entries not in
|
||||
// the vendored list) in memory. This keeps runtime memory usage small.
|
||||
// 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 domain-checker checks the in-memory delta first (fresh/recent scam
|
||||
// sites), then falls back to the vendored list.
|
||||
// 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:
|
||||
//
|
||||
// If the delta and its fetch timestamp fit in 256 KiB they are persisted to
|
||||
// extension storage, so they survive termination of the MV3 service worker.
|
||||
// Extension storage, not localStorage: localStorage does not exist in a
|
||||
// service worker, so the previous persistence never ran on Chrome at all.
|
||||
// The stored timestamps are what keep a restarted worker from re-fetching on
|
||||
// every wake while still noticing an overdue update. Those guards apply to the
|
||||
// startup path only; the 24-hour alarm tick bypasses them, or it would veto
|
||||
// its own refresh — see updatePhishingList().
|
||||
// - 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 vendoredConfig = require("./phishingBlocklist.json");
|
||||
const { storageLocal } = require("./browserApi");
|
||||
const vendored = require("./phishingBlocklist.json");
|
||||
const { HASH_ALGORITHM, HASH_HEX_CHARS, hashDomain } = require("./domainHash");
|
||||
|
||||
const BLOCKLIST_URL =
|
||||
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
|
||||
// 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.",
|
||||
);
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// Floor on how often an unscheduled path may hit the network. The worker is
|
||||
// revived every ~30 seconds while the browser is busy, and every revival runs
|
||||
// the startup path; without a persisted record of the last attempt, any state
|
||||
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large
|
||||
// to store — would download the full list on every single wake.
|
||||
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const DELTA_STORAGE_KEY = "phishing-delta";
|
||||
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
|
||||
|
||||
// Vendored set — built once from the bundled JSON.
|
||||
const vendoredBlacklist = new Set(
|
||||
(vendoredConfig.blacklist || []).map((d) => d.toLowerCase()),
|
||||
);
|
||||
|
||||
// Delta set — only entries from live list that are NOT in vendored.
|
||||
let deltaBlacklist = new Set();
|
||||
let lastFetchTime = 0;
|
||||
let lastAttemptTime = 0;
|
||||
let fetchPromise = null;
|
||||
let loadPromise = null;
|
||||
|
||||
// storageLocal() resolves on use rather than at module load, so a test can
|
||||
// install a stub after requiring this module, and it returns null where the
|
||||
// API is absent — which is why the popup, with no reason to touch the delta,
|
||||
// loads fine without it.
|
||||
|
||||
/**
|
||||
* Sanitise a timestamp read back from storage.
|
||||
*
|
||||
* A value in the future is permanent poison: every guard here measures elapsed
|
||||
* time as `Date.now() - stamp` and tests only the lower bound, so a stamp a
|
||||
* year ahead suppresses updates for a year with no path that ever clears it.
|
||||
* Clock skew and a restored profile backup both produce one. Since these
|
||||
* timestamps only ever gate work, discarding an impossible one is safe: it
|
||||
* costs at most a single extra fetch and restores a sane value immediately.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {number} the timestamp, or 0 if it is unusable.
|
||||
*/
|
||||
function sanitizeTimestamp(value) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
||||
if (value <= 0 || value > Date.now()) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the persisted delta and its timestamps from extension storage.
|
||||
* Runs once per worker lifetime; every entry point funnels through
|
||||
* ensureDeltaLoaded() so a wake from termination restores state exactly once.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function loadDeltaFromStorage() {
|
||||
const storage = storageLocal();
|
||||
if (!storage) return;
|
||||
try {
|
||||
const result = await storage.get(DELTA_STORAGE_KEY);
|
||||
const data = result && result[DELTA_STORAGE_KEY];
|
||||
if (!data) return;
|
||||
if (Array.isArray(data.blacklist)) {
|
||||
deltaBlacklist = new Set(
|
||||
data.blacklist.map((d) => d.toLowerCase()),
|
||||
);
|
||||
}
|
||||
lastFetchTime = sanitizeTimestamp(data.lastFetchTime);
|
||||
lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime);
|
||||
} catch {
|
||||
// Storage unavailable or corrupt — start empty and re-fetch.
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDeltaLoaded() {
|
||||
if (!loadPromise) loadPromise = loadDeltaFromStorage();
|
||||
return loadPromise;
|
||||
}
|
||||
checkArtifact(vendored);
|
||||
|
||||
const HASHES = vendored.hashes;
|
||||
const COUNT = vendored.count;
|
||||
|
||||
/**
|
||||
* Persist the delta and its timestamps if they fit within MAX_DELTA_BYTES.
|
||||
* Is this digest one of the vendored entries?
|
||||
*
|
||||
* The 256 KiB cap covers the delta and its freshness claim: when the delta is
|
||||
* too large to keep, lastFetchTime goes with it, so the next start re-fetches
|
||||
* rather than trusting a freshness claim for a delta it no longer holds.
|
||||
* lastAttemptTime is written either way — it records that the network was
|
||||
* contacted, which stays true whatever became of the response, and it is what
|
||||
* stops a permanently oversized list from downloading on every worker wake.
|
||||
* 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.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
* @param {string} hash
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async function saveDeltaToStorage() {
|
||||
const storage = storageLocal();
|
||||
if (!storage) return;
|
||||
try {
|
||||
const data = {
|
||||
blacklist: Array.from(deltaBlacklist),
|
||||
lastFetchTime,
|
||||
lastAttemptTime,
|
||||
};
|
||||
const json = JSON.stringify(data);
|
||||
if (json.length < MAX_DELTA_BYTES) {
|
||||
await storage.set({ [DELTA_STORAGE_KEY]: data });
|
||||
} else if (lastAttemptTime > 0) {
|
||||
await storage.set({ [DELTA_STORAGE_KEY]: { lastAttemptTime } });
|
||||
} else {
|
||||
await storage.remove(DELTA_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Storage unavailable — skip silently
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a pre-parsed config and compute the delta against the vendored list.
|
||||
* Used for both live fetches and testing.
|
||||
*
|
||||
* @param {{ blacklist?: string[] }} config
|
||||
* @returns {Promise<void>} resolves once the delta has been persisted.
|
||||
*/
|
||||
function loadConfig(config) {
|
||||
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
|
||||
|
||||
// Delta = entries in the live list that are NOT in the vendored list
|
||||
deltaBlacklist = new Set(
|
||||
liveBlacklist.filter((d) => !vendoredBlacklist.has(d)),
|
||||
);
|
||||
|
||||
lastFetchTime = Date.now();
|
||||
return saveDeltaToStorage();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,161 +126,33 @@ function hostnameVariants(hostname) {
|
||||
|
||||
/**
|
||||
* Check if a hostname is on the phishing blocklist.
|
||||
* Checks delta first (fresh/recent scam sites), then vendored list.
|
||||
*
|
||||
* Synchronous by design — callers answer an approval prompt with it. On a
|
||||
* worker that has just woken, the persisted delta may still be loading; the
|
||||
* vendored list, which is bundled and always present, carries the check until
|
||||
* it lands.
|
||||
*
|
||||
* @param {string} hostname - The hostname to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPhishingDomain(hostname) {
|
||||
if (!hostname) return false;
|
||||
const variants = hostnameVariants(hostname);
|
||||
|
||||
// Check delta blacklist first (fresh/recent scam sites), then vendored
|
||||
for (const v of variants) {
|
||||
if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true;
|
||||
for (const variant of hostnameVariants(hostname)) {
|
||||
if (hashListed(hashDomain(variant))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest blocklist and compute delta against vendored data.
|
||||
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS,
|
||||
* counted from the persisted timestamp so the cache outlives the worker.
|
||||
*
|
||||
* `force` is what makes the 24-hour alarm actually refresh every 24 hours.
|
||||
* The alarm fires one period after the previous alarm, but lastFetchTime is
|
||||
* stamped when that fetch *completed*, so an unforced tick lands one fetch
|
||||
* latency inside its own TTL, skips, and turns the real cadence into 48 hours.
|
||||
* Shortening the TTL instead would not fix it: the worker wakes every ~30
|
||||
* seconds and the startup path re-checks the TTL each time, so a shortened TTL
|
||||
* simply becomes the real cadence. The TTL is there to stop redundant fetches
|
||||
* on wake, and the scheduled tick is not redundant, so it bypasses it.
|
||||
*
|
||||
* @param {{force?: boolean}} [opts] force: fetch unless one is already in
|
||||
* flight, ignoring both the freshness and the retry guard. For the scheduled
|
||||
* alarm tick only.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updatePhishingList({ force = false } = {}) {
|
||||
// A worker that has just been revived knows nothing until the persisted
|
||||
// record is back in memory; without this the freshness check below would
|
||||
// always see 0 and re-fetch on every wake.
|
||||
await ensureDeltaLoaded();
|
||||
|
||||
if (!force) {
|
||||
const now = Date.now();
|
||||
// Skip if recently fetched.
|
||||
if (lastFetchTime > 0 && now - lastFetchTime < CACHE_TTL_MS) return;
|
||||
// Skip if the network was contacted recently and the result was not
|
||||
// usable — a failed fetch or an oversized delta leaves lastFetchTime
|
||||
// unset, and without this every wake would retry.
|
||||
if (
|
||||
lastAttemptTime > 0 &&
|
||||
now - lastAttemptTime < MIN_FETCH_ATTEMPT_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate concurrent calls
|
||||
if (fetchPromise) return fetchPromise;
|
||||
|
||||
fetchPromise = (async () => {
|
||||
lastAttemptTime = Date.now();
|
||||
try {
|
||||
const resp = await fetch(BLOCKLIST_URL);
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
const config = await resp.json();
|
||||
await loadConfig(config);
|
||||
} catch {
|
||||
// Silently fail — vendored list still provides coverage. Persist
|
||||
// the attempt so a persistently failing fetch is retried on the
|
||||
// schedule rather than on every wake.
|
||||
await saveDeltaToStorage();
|
||||
} finally {
|
||||
fetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore persisted state and fetch if the list is overdue.
|
||||
*
|
||||
* Called from the background script every time it starts — a fresh install,
|
||||
* a browser start, and every revival of a terminated service worker all land
|
||||
* here. The recurring 24-hour schedule itself is an alarm (see
|
||||
* shared/alarms.js), not a timer, because timers die with the worker.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function initPhishingList() {
|
||||
await ensureDeltaLoaded();
|
||||
return updatePhishingList();
|
||||
}
|
||||
|
||||
/**
|
||||
* The 24-hour alarm tick. Separate from initPhishingList() because this is the
|
||||
* scheduled refresh and must not be vetoed by the guards that exist to keep
|
||||
* the unscheduled startup path off the network.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function refreshPhishingListOnSchedule() {
|
||||
return updatePhishingList({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the total blocklist size (vendored + delta) for diagnostics.
|
||||
* Return the blocklist size for diagnostics.
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
function getBlocklistSize() {
|
||||
return vendoredBlacklist.size + deltaBlacklist.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the delta blocklist size for diagnostics.
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
function getDeltaSize() {
|
||||
return deltaBlacklist.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state (for testing).
|
||||
*/
|
||||
function _reset() {
|
||||
deltaBlacklist = new Set();
|
||||
lastFetchTime = 0;
|
||||
lastAttemptTime = 0;
|
||||
fetchPromise = null;
|
||||
loadPromise = null;
|
||||
return COUNT;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isPhishingDomain,
|
||||
updatePhishingList,
|
||||
refreshPhishingListOnSchedule,
|
||||
initPhishingList,
|
||||
loadDeltaFromStorage,
|
||||
loadConfig,
|
||||
CACHE_TTL_MS,
|
||||
MIN_FETCH_ATTEMPT_INTERVAL_MS,
|
||||
DELTA_STORAGE_KEY,
|
||||
MAX_DELTA_BYTES,
|
||||
getBlocklistSize,
|
||||
getDeltaSize,
|
||||
hostnameVariants,
|
||||
_reset,
|
||||
// Exposed for testing only
|
||||
_getVendoredBlacklistSize: () => vendoredBlacklist.size,
|
||||
_getDeltaBlacklist: () => deltaBlacklist,
|
||||
// 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user