343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
// 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<void>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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<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();
|
|
}
|
|
|
|
/**
|
|
* 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<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.
|
|
*
|
|
* @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,
|
|
};
|