+ ⚠️ PHISHING WARNING: This site is on MetaMask's phishing
+ blocklist. Connecting your wallet may result in loss of
+ funds. Proceed with extreme caution.
+
diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js
index d7abb53..fe2a4ab 100644
--- a/src/popup/views/approval.js
+++ b/src/popup/views/approval.js
@@ -13,6 +13,7 @@ const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
+const { isPhishingDomain } = require("../../shared/phishingDomains");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -155,7 +156,24 @@ function decodeCalldata(data, toAddress) {
return null;
}
+function showPhishingWarning(elementId, hostname, isPhishing) {
+ const el = $(elementId);
+ if (!el) return;
+ // Check both the flag from background and a local re-check
+ if (isPhishing || isPhishingDomain(hostname)) {
+ el.classList.remove("hidden");
+ } else {
+ el.classList.add("hidden");
+ }
+}
+
function showTxApproval(details) {
+ showPhishingWarning(
+ "approve-tx-phishing-warning",
+ details.hostname,
+ details.isPhishingDomain,
+ );
+
const toAddr = details.txParams.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(details.txParams.value || "0");
@@ -323,6 +341,12 @@ function formatTypedDataHtml(jsonStr) {
}
function showSignApproval(details) {
+ showPhishingWarning(
+ "approve-sign-phishing-warning",
+ details.hostname,
+ details.isPhishingDomain,
+ );
+
const sp = details.signParams;
$("approve-sign-hostname").textContent = details.hostname;
@@ -382,6 +406,12 @@ function show(id) {
showSignApproval(details);
return;
}
+ // Site connection approval
+ showPhishingWarning(
+ "approve-site-phishing-warning",
+ details.hostname,
+ details.isPhishingDomain,
+ );
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress,
diff --git a/src/shared/addressWarnings.js b/src/shared/addressWarnings.js
index ce4578d..986b800 100644
--- a/src/shared/addressWarnings.js
+++ b/src/shared/addressWarnings.js
@@ -4,6 +4,7 @@
const { isScamAddress } = require("./scamlist");
const { isBurnAddress } = require("./constants");
+const { checkEtherscanLabel } = require("./etherscanLabels");
const { log } = require("./log");
/**
@@ -92,6 +93,16 @@ async function getFullWarnings(address, provider, options = {}) {
log.errorf("tx count check failed:", e.message);
}
+ // Etherscan label check (best-effort async — network failures are silent).
+ try {
+ const etherscanWarning = await checkEtherscanLabel(address);
+ if (etherscanWarning) {
+ warnings.push(etherscanWarning);
+ }
+ } catch (e) {
+ log.errorf("etherscan label check failed:", e.message);
+ }
+
return warnings;
}
diff --git a/src/shared/etherscanLabels.js b/src/shared/etherscanLabels.js
new file mode 100644
index 0000000..9c8c658
--- /dev/null
+++ b/src/shared/etherscanLabels.js
@@ -0,0 +1,102 @@
+// Etherscan address label lookup via page scraping.
+// Extension users make the requests directly to Etherscan — no proxy needed.
+// This is a best-effort enrichment: network failures return null silently.
+
+const ETHERSCAN_BASE = "https://etherscan.io/address/";
+
+// Patterns in the page title that indicate a flagged address.
+// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
+const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
+
+// Patterns in the page body that indicate a scam/phishing warning.
+const SCAM_BODY_PATTERNS = [
+ /used in a\s+(?:\w+\s+)?phishing scam/i,
+ /used in a\s+(?:\w+\s+)?scam/i,
+ /wallet\s+drainer/i,
+];
+
+/**
+ * Parse the Etherscan address page HTML to extract label info.
+ * Exported for unit testing (no fetch needed).
+ *
+ * @param {string} html - Raw HTML of the Etherscan address page.
+ * @returns {{ label: string|null, isPhishing: boolean, warning: string|null }}
+ */
+function parseEtherscanPage(html) {
+ // Extract
content
+ const titleMatch = html.match(/]*>([^<]+)<\/title>/i);
+ let label = null;
+ let isPhishing = false;
+ let warning = null;
+
+ if (titleMatch) {
+ const title = titleMatch[1].trim();
+ // Title: "LABEL | Address: 0x... | Etherscan" or "Address: 0x... | Etherscan"
+ const labelMatch = title.match(/^(.+?)\s*\|\s*Address:/);
+ if (labelMatch) {
+ const candidate = labelMatch[1].trim();
+ // Only treat as a label if it's not just "Address" (unlabeled addresses)
+ if (candidate.toLowerCase() !== "address") {
+ label = candidate;
+ }
+ }
+ }
+
+ // Check label against phishing patterns
+ if (label) {
+ for (const pat of PHISHING_LABEL_PATTERNS) {
+ if (pat.test(label)) {
+ isPhishing = true;
+ warning = `Etherscan labels this address as "${label}" (Phish/Hack).`;
+ break;
+ }
+ }
+ }
+
+ // Check page body for scam warning banners
+ if (!isPhishing) {
+ for (const pat of SCAM_BODY_PATTERNS) {
+ if (pat.test(html)) {
+ isPhishing = true;
+ warning = label
+ ? `Etherscan labels this address as "${label}" and reports it was used in a scam.`
+ : "Etherscan reports this address was flagged for phishing/scam activity.";
+ break;
+ }
+ }
+ }
+
+ return { label, isPhishing, warning };
+}
+
+/**
+ * Fetch an address page from Etherscan and check for scam/phishing labels.
+ * Returns a warning object if the address is flagged, or null.
+ * Network failures return null silently (best-effort check).
+ *
+ * @param {string} address - Ethereum address to check.
+ * @returns {Promise<{type: string, message: string, severity: string}|null>}
+ */
+async function checkEtherscanLabel(address) {
+ try {
+ const resp = await fetch(ETHERSCAN_BASE + address, {
+ headers: { Accept: "text/html" },
+ });
+ if (!resp.ok) return null;
+ const html = await resp.text();
+ const result = parseEtherscanPage(html);
+ if (result.isPhishing) {
+ return {
+ type: "etherscan-phishing",
+ message: result.warning,
+ severity: "critical",
+ };
+ }
+ return null;
+ } catch {
+ // Network errors are expected — Etherscan may rate-limit or block.
+ return null;
+ }
+}
+
+module.exports = { parseEtherscanPage, checkEtherscanLabel };
diff --git a/src/shared/phishingDomains.js b/src/shared/phishingDomains.js
new file mode 100644
index 0000000..19b5aad
--- /dev/null
+++ b/src/shared/phishingDomains.js
@@ -0,0 +1,133 @@
+// Domain-based phishing detection using MetaMask's eth-phishing-detect blocklist.
+// Fetches the blocklist at runtime, caches it in memory, and checks hostnames.
+//
+// The blocklist source:
+// https://github.com/MetaMask/eth-phishing-detect (src/config.json)
+//
+// The config uses { blacklist: [...], whitelist: [...], fuzzylist: [...] }.
+// We check exact hostname and parent-domain matches against the blacklist,
+// with whitelist overrides.
+
+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
+
+let blacklistSet = new Set();
+let whitelistSet = new Set();
+let lastFetchTime = 0;
+let fetchPromise = null;
+
+/**
+ * Load a pre-parsed config into the in-memory sets.
+ * Used for testing and for loading from cache.
+ *
+ * @param {{ blacklist?: string[], whitelist?: string[] }} config
+ */
+function loadConfig(config) {
+ blacklistSet = new Set(
+ (config.blacklist || []).map((d) => d.toLowerCase()),
+ );
+ whitelistSet = new Set(
+ (config.whitelist || []).map((d) => d.toLowerCase()),
+ );
+ lastFetchTime = Date.now();
+}
+
+/**
+ * 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 exact hostname and all parent domains.
+ * Whitelisted domains are never flagged.
+ *
+ * @param {string} hostname - The hostname to check.
+ * @returns {boolean}
+ */
+function isPhishingDomain(hostname) {
+ if (!hostname) return false;
+ const variants = hostnameVariants(hostname);
+ // Whitelist takes priority
+ for (const v of variants) {
+ if (whitelistSet.has(v)) return false;
+ }
+ for (const v of variants) {
+ if (blacklistSet.has(v)) return true;
+ }
+ return false;
+}
+
+/**
+ * Fetch the latest blocklist from the MetaMask repo.
+ * De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS.
+ *
+ * @returns {Promise}
+ */
+async function updatePhishingList() {
+ // Skip if recently fetched
+ if (Date.now() - lastFetchTime < CACHE_TTL_MS && blacklistSet.size > 0) {
+ return;
+ }
+
+ // De-duplicate concurrent calls
+ if (fetchPromise) return fetchPromise;
+
+ fetchPromise = (async () => {
+ try {
+ const resp = await fetch(BLOCKLIST_URL);
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
+ const config = await resp.json();
+ loadConfig(config);
+ } catch {
+ // Silently fail — we'll retry next time.
+ } finally {
+ fetchPromise = null;
+ }
+ })();
+
+ return fetchPromise;
+}
+
+/**
+ * Return the current blocklist size (for diagnostics).
+ *
+ * @returns {number}
+ */
+function getBlocklistSize() {
+ return blacklistSet.size;
+}
+
+/**
+ * Reset internal state (for testing).
+ */
+function _reset() {
+ blacklistSet = new Set();
+ whitelistSet = new Set();
+ lastFetchTime = 0;
+ fetchPromise = null;
+}
+
+module.exports = {
+ isPhishingDomain,
+ updatePhishingList,
+ loadConfig,
+ getBlocklistSize,
+ hostnameVariants,
+ _reset,
+};
diff --git a/tests/etherscanLabels.test.js b/tests/etherscanLabels.test.js
new file mode 100644
index 0000000..b8f1b9d
--- /dev/null
+++ b/tests/etherscanLabels.test.js
@@ -0,0 +1,100 @@
+const { parseEtherscanPage } = require("../src/shared/etherscanLabels");
+
+describe("etherscanLabels", () => {
+ describe("parseEtherscanPage", () => {
+ test("detects Fake_Phishing label in title", () => {
+ const html = `Fake_Phishing184810 | Address: 0x00000c07...3ea470000 | Etherscan`;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBe("Fake_Phishing184810");
+ expect(result.isPhishing).toBe(true);
+ expect(result.warning).toContain("Fake_Phishing184810");
+ expect(result.warning).toContain("Phish/Hack");
+ });
+
+ test("detects Fake_Phishing with different number", () => {
+ const html = `Fake_Phishing5169 | Address: 0x3e0defb8...99a7a8a74 | Etherscan`;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBe("Fake_Phishing5169");
+ expect(result.isPhishing).toBe(true);
+ });
+
+ test("detects Exploiter label", () => {
+ const html = `Exploiter 42 | Address: 0xabcdef...1234 | Etherscan`;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBe("Exploiter 42");
+ expect(result.isPhishing).toBe(true);
+ });
+
+ test("detects scam warning in body text", () => {
+ const html =
+ `Address: 0xabcdef...1234 | Etherscan` +
+ `There are reports that this address was used in a Phishing scam.`;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBeNull();
+ expect(result.isPhishing).toBe(true);
+ expect(result.warning).toContain("phishing/scam");
+ });
+
+ test("detects scam warning with label in body", () => {
+ const html =
+ `SomeScammer | Address: 0xabcdef...1234 | Etherscan` +
+ `There are reports that this address was used in a scam.`;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBe("SomeScammer");
+ expect(result.isPhishing).toBe(true);
+ expect(result.warning).toContain("SomeScammer");
+ });
+
+ test("returns clean result for legitimate address", () => {
+ const html = `vitalik.eth | Address: 0xd8dA6BF2...37aA96045 | EtherscanOverview`;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBe("vitalik.eth");
+ expect(result.isPhishing).toBe(false);
+ expect(result.warning).toBeNull();
+ });
+
+ test("returns clean result for unlabeled address", () => {
+ const html = `Address: 0x1234567890...abcdef | EtherscanOverview`;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBeNull();
+ expect(result.isPhishing).toBe(false);
+ expect(result.warning).toBeNull();
+ });
+
+ test("handles exchange labels correctly (not phishing)", () => {
+ const html = `Coinbase 10 | Address: 0xa9d1e08c...b81d3e43 | EtherscanOverview`;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBe("Coinbase 10");
+ expect(result.isPhishing).toBe(false);
+ });
+
+ test("handles contract names correctly (not phishing)", () => {
+ const html = `Beacon Deposit Contract | Address: 0x00000000...03d7705Fa | EtherscanOverview`;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBe("Beacon Deposit Contract");
+ expect(result.isPhishing).toBe(false);
+ });
+
+ test("handles empty HTML gracefully", () => {
+ const result = parseEtherscanPage("");
+ expect(result.label).toBeNull();
+ expect(result.isPhishing).toBe(false);
+ expect(result.warning).toBeNull();
+ });
+
+ test("handles malformed title tag", () => {
+ const html = ``;
+ const result = parseEtherscanPage(html);
+ expect(result.label).toBeNull();
+ expect(result.isPhishing).toBe(false);
+ });
+
+ test("detects wallet drainer warning", () => {
+ const html =
+ `Address: 0xabc...def | Etherscan` +
+ `This is a known wallet drainer contract.`;
+ const result = parseEtherscanPage(html);
+ expect(result.isPhishing).toBe(true);
+ });
+ });
+});
diff --git a/tests/phishingDomains.test.js b/tests/phishingDomains.test.js
new file mode 100644
index 0000000..713e619
--- /dev/null
+++ b/tests/phishingDomains.test.js
@@ -0,0 +1,166 @@
+const {
+ isPhishingDomain,
+ loadConfig,
+ getBlocklistSize,
+ hostnameVariants,
+ _reset,
+} = require("../src/shared/phishingDomains");
+
+// Reset state before each test to avoid cross-test contamination.
+beforeEach(() => {
+ _reset();
+});
+
+describe("phishingDomains", () => {
+ 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("loadConfig + isPhishingDomain", () => {
+ test("detects exact blacklisted domain", () => {
+ loadConfig({
+ blacklist: ["evil-phishing.com", "scam-swap.xyz"],
+ whitelist: [],
+ });
+ expect(isPhishingDomain("evil-phishing.com")).toBe(true);
+ expect(isPhishingDomain("scam-swap.xyz")).toBe(true);
+ });
+
+ test("returns false for clean domains", () => {
+ loadConfig({
+ blacklist: ["evil-phishing.com"],
+ whitelist: [],
+ });
+ expect(isPhishingDomain("etherscan.io")).toBe(false);
+ expect(isPhishingDomain("uniswap.org")).toBe(false);
+ });
+
+ test("detects subdomain of blacklisted domain", () => {
+ loadConfig({
+ blacklist: ["evil-phishing.com"],
+ whitelist: [],
+ });
+ expect(isPhishingDomain("app.evil-phishing.com")).toBe(true);
+ expect(isPhishingDomain("sub.app.evil-phishing.com")).toBe(true);
+ });
+
+ test("whitelist overrides blacklist", () => {
+ loadConfig({
+ blacklist: ["metamask.io"],
+ whitelist: ["metamask.io"],
+ });
+ expect(isPhishingDomain("metamask.io")).toBe(false);
+ });
+
+ test("whitelist on parent domain overrides blacklist", () => {
+ loadConfig({
+ blacklist: ["sub.legit.com"],
+ whitelist: ["legit.com"],
+ });
+ expect(isPhishingDomain("sub.legit.com")).toBe(false);
+ });
+
+ test("case-insensitive matching", () => {
+ loadConfig({
+ blacklist: ["Evil-Phishing.COM"],
+ whitelist: [],
+ });
+ expect(isPhishingDomain("evil-phishing.com")).toBe(true);
+ expect(isPhishingDomain("EVIL-PHISHING.COM")).toBe(true);
+ });
+
+ test("returns false for empty/null hostname", () => {
+ loadConfig({
+ blacklist: ["evil.com"],
+ whitelist: [],
+ });
+ expect(isPhishingDomain("")).toBe(false);
+ expect(isPhishingDomain(null)).toBe(false);
+ });
+
+ test("getBlocklistSize reflects loaded config", () => {
+ loadConfig({
+ blacklist: ["a.com", "b.com", "c.com"],
+ whitelist: ["d.com"],
+ });
+ expect(getBlocklistSize()).toBe(3);
+ });
+
+ test("handles config with no blacklist/whitelist keys", () => {
+ loadConfig({});
+ expect(isPhishingDomain("anything.com")).toBe(false);
+ expect(getBlocklistSize()).toBe(0);
+ });
+
+ test("re-loading config replaces previous data", () => {
+ loadConfig({
+ blacklist: ["old-scam.com"],
+ whitelist: [],
+ });
+ expect(isPhishingDomain("old-scam.com")).toBe(true);
+
+ loadConfig({
+ blacklist: ["new-scam.com"],
+ whitelist: [],
+ });
+ expect(isPhishingDomain("old-scam.com")).toBe(false);
+ expect(isPhishingDomain("new-scam.com")).toBe(true);
+ });
+ });
+
+ describe("real-world MetaMask blocklist patterns", () => {
+ test("detects known phishing domains from MetaMask list", () => {
+ loadConfig({
+ blacklist: [
+ "uniswap-trade.web.app",
+ "hopprotocol.pro",
+ "blast-pools.pages.dev",
+ ],
+ whitelist: [],
+ });
+ 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 whitelisted by MetaMask", () => {
+ loadConfig({
+ blacklist: ["opensea.pro"],
+ whitelist: [
+ "opensea.io",
+ "metamask.io",
+ "etherscan.io",
+ "opensea.pro",
+ ],
+ });
+ expect(isPhishingDomain("opensea.io")).toBe(false);
+ expect(isPhishingDomain("metamask.io")).toBe(false);
+ expect(isPhishingDomain("etherscan.io")).toBe(false);
+ // opensea.pro is both blacklisted and whitelisted — whitelist wins
+ expect(isPhishingDomain("opensea.pro")).toBe(false);
+ });
+ });
+});