// Domain-based phishing detection using a vendored blocklist with delta updates. // // 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 domain-checker checks the in-memory delta first (fresh/recent scam // sites), then falls back to the vendored list. // // 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(). const vendoredConfig = require("./phishingBlocklist.json"); const BLOCKLIST_URL = "https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json"; 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; // Resolved on use rather than captured at module load, so a test can install // a stub after requiring the module and so the popup — which has no reason to // touch the delta — does not fail to load where the API is absent. function storageApi() { if (typeof browser !== "undefined" && browser.storage) { return browser.storage.local; } if (typeof chrome !== "undefined" && chrome.storage) { return chrome.storage.local; } return null; } /** * 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} */ async function loadDeltaFromStorage() { const storage = storageApi(); 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. } } function ensureDeltaLoaded() { if (!loadPromise) loadPromise = loadDeltaFromStorage(); return loadPromise; } /** * Persist the delta and its timestamps if they fit within MAX_DELTA_BYTES. * * 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. * * @returns {Promise} */ async function saveDeltaToStorage() { const storage = storageApi(); 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 } } /** * 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} 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(); } /** * Generate hostname variants for subdomain matching. * "sub.evil.com" yields ["sub.evil.com", "evil.com"]. * * @param {string} hostname * @returns {string[]} */ function hostnameVariants(hostname) { const h = hostname.toLowerCase(); const variants = [h]; const parts = h.split("."); // Parent domains: a.b.c.d -> b.c.d, c.d for (let i = 1; i < parts.length - 1; i++) { variants.push(parts.slice(i).join(".")); } return variants; } /** * 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; } 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} */ 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} */ 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} */ async function refreshPhishingListOnSchedule() { return updatePhishingList({ force: true }); } /** * Return the total blocklist size (vendored + delta) 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; } 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, };