From 5a96f97ab47e87c92d2b30ad5b3a92d5caf0ce5e Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Thu, 19 Mar 2026 16:16:12 +0100 Subject: [PATCH] fix tests --- packages/phishing-controller/package.json | 2 + .../PhishingController-method-action-types.ts | 125 + .../src/PhishingController.test.ts | 3210 ++++++++--------- .../src/PhishingController.ts | 169 +- packages/phishing-controller/src/index.ts | 10 + .../phishing-controller/src/tests/utils.ts | 13 + yarn.lock | 1 + 7 files changed, 1801 insertions(+), 1729 deletions(-) create mode 100644 packages/phishing-controller/src/PhishingController-method-action-types.ts diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 582ced25e4b..17f0a1ec21b 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -40,6 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/phishing-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/phishing-controller", + "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", @@ -65,6 +66,7 @@ "jest": "^29.7.0", "nock": "^13.3.1", "ts-jest": "^29.2.5", + "tsx": "^4.20.5", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" diff --git a/packages/phishing-controller/src/PhishingController-method-action-types.ts b/packages/phishing-controller/src/PhishingController-method-action-types.ts new file mode 100644 index 00000000000..454b4c468a7 --- /dev/null +++ b/packages/phishing-controller/src/PhishingController-method-action-types.ts @@ -0,0 +1,125 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { PhishingController } from './PhishingController'; + +/** + * Conditionally update the phishing configuration. + * + * If the stalelist configuration is out of date, this function will call `#updateStalelist` + * to update the configuration. This will automatically grab the hotlist, + * so it isn't necessary to continue on to download the hotlist and the c2 domain blocklist. + * + */ +export type PhishingControllerMaybeUpdateStateAction = { + type: `PhishingController:maybeUpdateState`; + handler: PhishingController['maybeUpdateState']; +}; + +/** + * Determines if a given origin is unapproved. + * + * It is strongly recommended that you call {@link maybeUpdateState} before calling this, + * to check whether the phishing configuration is up-to-date. It will be updated if necessary + * by calling {@link #updateStalelist} or {@link #updateHotlist}. + * + * @param origin - Domain origin of a website. + * @returns Whether the origin is an unapproved origin. + */ +export type PhishingControllerTestAction = { + type: `PhishingController:test`; + handler: PhishingController['test']; +}; + +/** + * Checks if a request URL's domain is blocked against the request blocklist. + * + * This method is used to determine if a specific request URL is associated with a malicious + * command and control (C2) domain. The URL's hostname is hashed and checked against a configured + * blocklist of known malicious domains. + * + * @param origin - The full request URL to be checked. + * @returns An object indicating whether the URL's domain is blocked and relevant metadata. + */ +export type PhishingControllerIsBlockedRequestAction = { + type: `PhishingController:isBlockedRequest`; + handler: PhishingController['isBlockedRequest']; +}; + +/** + * Temporarily marks a given origin as approved. + * + * @param origin - The origin to mark as approved. + */ +export type PhishingControllerBypassAction = { + type: `PhishingController:bypass`; + handler: PhishingController['bypass']; +}; + +/** + * Scan a URL for phishing. It will only scan the hostname of the URL. It also only supports + * web URLs. + * + * @param url - The URL to scan. + * @returns The phishing detection scan result. + */ +export type PhishingControllerScanUrlAction = { + type: `PhishingController:scanUrl`; + handler: PhishingController['scanUrl']; +}; + +/** + * Scan multiple URLs for phishing in bulk. It will only scan the hostnames of the URLs. + * It also only supports web URLs. + * + * @param urls - The URLs to scan. + * @returns A mapping of URLs to their phishing detection scan results and errors. + */ +export type PhishingControllerBulkScanUrlsAction = { + type: `PhishingController:bulkScanUrls`; + handler: PhishingController['bulkScanUrls']; +}; + +/** + * Scan an address for security alerts. + * + * @param chainId - The chain ID in hex format (e.g., '0x1' for Ethereum). + * @param address - The address to scan. + * @returns The address scan result. + */ +export type PhishingControllerScanAddressAction = { + type: `PhishingController:scanAddress`; + handler: PhishingController['scanAddress']; +}; + +/** + * Scan multiple tokens for malicious activity in bulk. + * + * @param request - The bulk scan request containing chainId and tokens. + * @param request.chainId - The chain identifier. Accepts a hex chain ID for + * EVM chains (e.g. `'0x1'` for Ethereum) or a chain name for non-EVM chains + * (e.g. `'solana'`). + * @param request.tokens - Array of token addresses to scan. + * @returns A mapping of token addresses to their scan results. For EVM chains, + * addresses are lowercased; for non-EVM chains, original casing is preserved. + * Tokens that fail to scan are omitted. + */ +export type PhishingControllerBulkScanTokensAction = { + type: `PhishingController:bulkScanTokens`; + handler: PhishingController['bulkScanTokens']; +}; + +/** + * Union of all PhishingController action types. + */ +export type PhishingControllerMethodActions = + | PhishingControllerMaybeUpdateStateAction + | PhishingControllerTestAction + | PhishingControllerIsBlockedRequestAction + | PhishingControllerBypassAction + | PhishingControllerScanUrlAction + | PhishingControllerBulkScanUrlsAction + | PhishingControllerScanAddressAction + | PhishingControllerBulkScanTokensAction; diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index fe3a3bd1b1f..54a46baa018 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -31,6 +31,7 @@ import { createMockStateChangePayload, createMockTransaction, formatHostnameToUrl, + isOutOfDate, TEST_ADDRESSES, } from './tests/utils'; import type { PhishingDetectionScanResult, AddressScanResult } from './types'; @@ -39,7 +40,7 @@ import { RecommendedAction, AddressScanResultType, } from './types'; -import { getHostnameFromUrl } from './utils'; +import { fetchTimeNow, getHostnameFromUrl } from './utils'; const controllerName = 'PhishingController'; @@ -193,7 +194,9 @@ describe('PhishingController', () => { }); const controller = getPhishingController(); - await controller.updateStalelist(); + + await controller.maybeUpdateState(); + const result = controller.test(`https://${allowlistedHostname}/path`); expect(result).toMatchObject({ @@ -223,68 +226,21 @@ describe('PhishingController', () => { expect(nockScope.isDone()).toBe(false); }); - it('should not re-request when an update is in progress', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const nockScope = nock(PHISHING_CONFIG_BASE_URL) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .delay(500) // delay promise resolution to generate "pending" state that lasts long enough to test. - .reply(200, { - data: [ - { - url: 'this-should-not-be-in-default-blocklist.com', - timestamp: 1, - isRemoval: true, - targetList: 'eth_phishing_detect_config.blocklist', - }, - { - url: 'this-should-not-be-in-default-blocklist.com', - timestamp: 2, - targetList: 'eth_phishing_detect_config.blocklist', - }, - ], - }); - - const controller = getPhishingController({ - hotlistRefreshInterval: 10, - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 0, - lastUpdated: 1, - name: ListNames.MetaMask, - version: 0, - }, - ], - }, - }); - jest.advanceTimersByTime(1000 * 10); - const pendingUpdate = controller.updateHotlist(); - - expect(controller.isHotlistOutOfDate()).toBe(true); - const pendingUpdateTwo = controller.updateHotlist(); - expect(nockScope.activeMocks()).toHaveLength(1); - - // Cleanup pending operations - await pendingUpdate; - await pendingUpdateTwo; - }); - describe('maybeUpdateState', () => { - let nockScope: nock.Scope; - beforeEach(() => { - nockScope = nock(PHISHING_CONFIG_BASE_URL) + it('should update all phishing lists when maybeUpdateState is called and all lists are out of date', async () => { + const exampleBlockedUrl = 'example-blocked-website.com'; + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + const exampleBlockedUrlOne = + 'https://another-example-blocked-website.com'; + nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - blocklist: ['this-should-not-be-in-default-blocklist.com'], + allowlist: [], + blocklist: [exampleBlockedUrl], blocklistPaths: [], fuzzylist: [], - allowlist: ['this-should-not-be-in-default-allowlist.com'], tolerance: 0, version: 0, lastUpdated: 1, @@ -294,14 +250,8 @@ describe('PhishingController', () => { .reply(200, { data: [ { - url: 'this-should-not-be-in-default-blocklist.com', + url: exampleBlockedUrlOne, timestamp: 2, - isRemoval: true, - targetList: 'eth_phishing_detect_config.blocklist', - }, - { - url: 'this-should-not-be-in-default-blocklist.com', - timestamp: 3, targetList: 'eth_phishing_detect_config.blocklist', }, ], @@ -310,345 +260,308 @@ describe('PhishingController', () => { nock(CLIENT_SIDE_DETECION_BASE_URL) .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) .reply(200, { - recentlyAdded: [], + recentlyAdded: [exampleRequestBlockedHash], recentlyRemoved: [], lastFetchedAt: 1, }); - }); - - it('should not have stalelist be out of date immediately after maybeUpdateState is called', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - stalelistRefreshInterval: 10, - }); - jest.advanceTimersByTime(1000 * 10); - expect(controller.isStalelistOutOfDate()).toBe(true); - await controller.maybeUpdateState(); - expect(controller.isStalelistOutOfDate()).toBe(false); - expect(nockScope.isDone()).toBe(true); - }); - - it('should not be out of date after maybeUpdateStalelist is called but before refresh interval has passed', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - stalelistRefreshInterval: 10, - }); - jest.advanceTimersByTime(1000 * 10); - expect(controller.isStalelistOutOfDate()).toBe(true); - await controller.maybeUpdateState(); - jest.advanceTimersByTime(1000 * 5); - expect(controller.isStalelistOutOfDate()).toBe(false); - expect(nockScope.isDone()).toBe(true); - }); - - it('should still be out of date while update is in progress', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - stalelistRefreshInterval: 10, - }); - jest.advanceTimersByTime(1000 * 10); - // do not wait - const maybeUpdatePhisingListPromise = controller.maybeUpdateState(); - expect(controller.isStalelistOutOfDate()).toBe(true); - await maybeUpdatePhisingListPromise; - expect(controller.isStalelistOutOfDate()).toBe(false); - jest.advanceTimersByTime(1000 * 10); - expect(controller.isStalelistOutOfDate()).toBe(true); - expect(nockScope.isDone()).toBe(true); - }); - - it('should call update only if it is out of date, otherwise it should not call update', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - stalelistRefreshInterval: 10, - }); - expect(controller.isStalelistOutOfDate()).toBe(false); - await controller.maybeUpdateState(); - expect( - controller.test( - formatHostnameToUrl('this-should-not-be-in-default-blocklist.com'), - ), - ).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, - }); - - expect( - controller.test( - formatHostnameToUrl('this-should-not-be-in-default-allowlist.com'), - ), - ).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, - }); - jest.advanceTimersByTime(1000 * 10); + const controller = getPhishingController(); await controller.maybeUpdateState(); - expect( - controller.test( - formatHostnameToUrl('this-should-not-be-in-default-blocklist.com'), - ), - ).toMatchObject({ - result: true, - type: PhishingDetectorResultType.Blocklist, - }); - - expect( - controller.test( - formatHostnameToUrl('this-should-not-be-in-default-allowlist.com'), - ), - ).toMatchObject({ - result: false, - type: PhishingDetectorResultType.Allowlist, - }); - - expect(nockScope.isDone()).toBe(true); + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [exampleBlockedUrl, exampleBlockedUrlOne], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 0, + lastUpdated: 2, + name: ListNames.MetaMask, + version: 0, + }, + ]); }); - it('should not have hotlist be out of date immediately after maybeUpdateState is called', async () => { - nockScope = nock(PHISHING_CONFIG_BASE_URL) + it('should remove an entry from the blocklist when the hotlist indicates it should be removed', async () => { + const exampleBlockedUrl = 'example-blocked-website.com'; + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + const exampleBlockedUrlTwo = 'another-example-blocked-website.com'; + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: [exampleBlockedUrl], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [ { - url: 'this-should-not-be-in-default-blocklist.com', - timestamp: 1, - isRemoval: true, + url: exampleBlockedUrlTwo, + timestamp: 2, targetList: 'eth_phishing_detect_config.blocklist', }, { - url: 'this-should-not-be-in-default-blocklist.com', + url: exampleBlockedUrl, timestamp: 2, targetList: 'eth_phishing_detect_config.blocklist', + isRemoval: true, }, ], }); - jest.useFakeTimers({ - doNotFake: ['nextTick', 'queueMicrotask'], - now: 50, - }); - const controller = getPhishingController({ - hotlistRefreshInterval: 10, - stalelistRefreshInterval: 50, - }); - jest.advanceTimersByTime(1000 * 10); - expect(controller.isHotlistOutOfDate()).toBe(true); - await controller.maybeUpdateState(); - expect(controller.isHotlistOutOfDate()).toBe(false); - }); - it('should not have c2DomainBlocklist be out of date immediately after maybeUpdateState is called', async () => { - nockScope = nock(CLIENT_SIDE_DETECION_BASE_URL) + nock(CLIENT_SIDE_DETECION_BASE_URL) .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) .reply(200, { - recentlyAdded: [], + recentlyAdded: [exampleRequestBlockedHash], recentlyRemoved: [], lastFetchedAt: 1, }); - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - c2DomainBlocklistRefreshInterval: 10, - }); - jest.advanceTimersByTime(1000 * 10); - expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); + + const controller = getPhishingController(); await controller.maybeUpdateState(); - expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); - }); - it('replaces existing phishing lists with completely new list from phishing detection API', async () => { - const { messenger } = setupMessenger(); - const controller = new PhishingController({ - messenger, - stalelistRefreshInterval: 10, - state: { - phishingLists: [ - { - allowlist: ['initial-safe-site.com'], - blocklist: ['new-phishing-site.com'], - blocklistPaths: {}, - c2DomainBlocklist: [], - fuzzylist: ['new-fuzzy-site.com'], - tolerance: 2, - version: 1, - lastUpdated: 1, - name: ListNames.MetaMask, - }, - ], - whitelist: [], - whitelistPaths: {}, - hotlistLastFetched: 0, - stalelistLastFetched: 0, - c2DomainBlocklistLastFetched: 0, - urlScanCache: {}, + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [exampleBlockedUrlTwo], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 2, + name: ListNames.MetaMask, }, - }); + ]); + }); - cleanAll(); + it('should correctly process blocklist entries with paths into blocklistPaths', async () => { nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - blocklist: [], - blocklistPaths: ['example.com/path'], - fuzzylist: ['new-fuzzy-site.com'], - allowlist: ['new-safe-site.com'], - tolerance: 2, - version: 2, - lastUpdated: 2, + allowlist: [], + blocklist: ['example.com'], + blocklistPaths: ['malicious.com/phishing'], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, }, }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${2}`) - .reply(200, { - data: [], - }); + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + nock(CLIENT_SIDE_DETECION_BASE_URL) .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) .reply(200, { recentlyAdded: [], recentlyRemoved: [], - lastFetchedAt: 2, + lastFetchedAt: 1, }); - // Force the stalelist to be out of date and trigger update - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - jest.advanceTimersByTime(1000 * 10); - + const controller = getPhishingController(); await controller.maybeUpdateState(); - expect(controller.state.phishingLists).toStrictEqual([ { - allowlist: ['new-safe-site.com'], - blocklist: [], + allowlist: [], + blocklist: ['example.com'], + c2DomainBlocklist: [], blocklistPaths: { - 'example.com': { - path: {}, + 'malicious.com': { + phishing: {}, }, }, - c2DomainBlocklist: [], - fuzzylist: ['new-fuzzy-site.com'], - tolerance: 2, - version: 2, - lastUpdated: 2, + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, name: ListNames.MetaMask, }, ]); - - jest.useRealTimers(); }); - }); - describe('isStalelistOutOfDate', () => { - it('should not be out of date upon construction', () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + it('should not update phishing lists if fetch returns 304', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(304) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(304); + const controller = getPhishingController({ - stalelistRefreshInterval: 10, + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + }, }); + await controller.maybeUpdateState(); - expect(controller.isStalelistOutOfDate()).toBe(false); + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ]); }); - it('should not be out of date after some of the refresh interval has passed', () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - stalelistRefreshInterval: 10, - }); - jest.advanceTimersByTime(1000 * 5); + it('should not update phishing lists if fetch returns 500', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(500) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(500); - expect(controller.isStalelistOutOfDate()).toBe(false); - }); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(500); - it('should be out of date after the refresh interval has passed', () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ - stalelistRefreshInterval: 10, + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + }, }); - jest.advanceTimersByTime(1000 * 10); + await controller.maybeUpdateState(); - expect(controller.isStalelistOutOfDate()).toBe(true); + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ]); }); - it('should be out of date if the refresh interval has passed and an update is in progress', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - stalelistRefreshInterval: 10, - }); - jest.advanceTimersByTime(1000 * 10); - const pendingUpdate = controller.updateStalelist(); - - expect(controller.isStalelistOutOfDate()).toBe(true); + it('should not throw when there is a network error', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .replyWithError('network error') + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .replyWithError('network error'); - // Cleanup pending operations - await pendingUpdate; - }); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .replyWithError('network error'); - it('should not be out of date if the phishing lists were just updated', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - stalelistRefreshInterval: 10, - }); - await controller.updateStalelist(); + const controller = getPhishingController(); - expect(controller.isStalelistOutOfDate()).toBe(false); + expect(await controller.maybeUpdateState()).toBeUndefined(); }); - it('should not be out of date if the phishing lists were recently updated', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - stalelistRefreshInterval: 10, - }); - await controller.updateStalelist(); - jest.advanceTimersByTime(1000 * 5); + describe('an update is in progress', () => { + it('should not fetch phishing lists again', async () => { + const nockScope = nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .delay(100) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .delay(100) + .reply(200, { data: [] }); - expect(controller.isStalelistOutOfDate()).toBe(false); - }); + const controller = getPhishingController(); + const firstPromise = controller.maybeUpdateState(); + const secondPromise = controller.maybeUpdateState(); - it('should be out of date if the time elapsed since the last update equals the refresh interval', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - stalelistRefreshInterval: 10, - }); - await controller.updateStalelist(); - jest.advanceTimersByTime(1000 * 10); + jest.advanceTimersByTime(1000 * 100); - expect(controller.isStalelistOutOfDate()).toBe(true); - }); - }); + await firstPromise; + await secondPromise; - describe('isHotlistOutOfDate', () => { - it('should not be out of date upon construction', () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - hotlistRefreshInterval: 10, + // This second update would throw if it fetched, because the + // nock interceptor was not persisted. + expect(nockScope.isDone()).toBe(true); }); - expect(controller.isHotlistOutOfDate()).toBe(false); - }); + it('should wait until the in-progress update has completed', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .delay(100) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .delay(100) + .reply(200, { data: [] }); - it('should not be out of date after some of the refresh interval has passed', () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - hotlistRefreshInterval: 10, - }); - jest.advanceTimersByTime(1000 * 5); + const controller = getPhishingController(); + const firstPromise = controller.maybeUpdateState(); + const secondPromise = controller.maybeUpdateState(); + jest.advanceTimersByTime(1000 * 99); - expect(controller.isHotlistOutOfDate()).toBe(false); - }); + await expect(secondPromise).toNeverResolve(); - it('should be out of date after the refresh interval has passed', () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - hotlistRefreshInterval: 10, + // Cleanup pending operations + await firstPromise; + await secondPromise; }); - jest.advanceTimersByTime(1000 * 10); - - expect(controller.isHotlistOutOfDate()).toBe(true); }); - it('should be out of date if the refresh interval has passed and an update is in progress', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + it('should not update phishing lists if hotlist fetch returns 404', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${0}`) + .reply(404); + const controller = getPhishingController({ - hotlistRefreshInterval: 10, state: { phishingLists: [ { @@ -657,762 +570,645 @@ describe('PhishingController', () => { c2DomainBlocklist: [], blocklistPaths: {}, fuzzylist: [], - tolerance: 0, - lastUpdated: 1, + tolerance: 3, + version: 1, name: ListNames.MetaMask, - version: 0, + lastUpdated: 0, }, ], }, }); - jest.advanceTimersByTime(1000 * 10); - const pendingUpdate = controller.updateHotlist(); - - expect(controller.isHotlistOutOfDate()).toBe(true); - - // Cleanup pending operations - await pendingUpdate; - }); - - it('should not be out of date if the phishing lists were just updated', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - hotlistRefreshInterval: 10, - }); - await controller.updateHotlist(); + await controller.maybeUpdateState(); - expect(controller.isHotlistOutOfDate()).toBe(false); + expect(controller.state.phishingLists).toStrictEqual([ + { + ...controller.state.phishingLists[0], + lastUpdated: 0, + }, + ]); }); - it('should not be out of date if the phishing lists were recently updated', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - hotlistRefreshInterval: 10, - }); - await controller.updateHotlist(); - jest.advanceTimersByTime(1000 * 5); - - expect(controller.isHotlistOutOfDate()).toBe(false); - }); - - it('should be out of date if the time elapsed since the last update equals the refresh interval', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - hotlistRefreshInterval: 10, - }); - await controller.updateHotlist(); - jest.advanceTimersByTime(1000 * 10); - - expect(controller.isHotlistOutOfDate()).toBe(true); - }); - }); + it('should not make API calls to update hotlist when phishingLists array is empty', async () => { + const testBlockedDomain = 'some-test-blocked-url.com'; + const hotlistNock = nock(PHISHING_CONFIG_BASE_URL) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${0}`) + .reply(200, { + data: [ + { + targetList: 'eth_phishing_detect_config.blocklist', + url: testBlockedDomain, + timestamp: 1, + }, + ], + }); - describe('isC2DomainBlocklistOutOfDate', () => { - it('should not be out of date upon construction', () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ - c2DomainBlocklistRefreshInterval: 10, + state: { + phishingLists: [], + }, }); + await controller.maybeUpdateState(); - expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); + expect(hotlistNock.isDone()).toBe(false); }); - it('should not be out of date after some of the refresh interval has passed', () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - c2DomainBlocklistRefreshInterval: 10, - }); - jest.advanceTimersByTime(1000 * 5); + it('should handle empty hotlist and request blocklist responses gracefully', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/0`) + .reply(200, { data: [] }); - expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); - }); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); - it('should be out of date after the refresh interval has passed', () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ - c2DomainBlocklistRefreshInterval: 10, + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + }, }); - jest.advanceTimersByTime(1000 * 10); - - expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); + await controller.maybeUpdateState(); + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ]); }); - it('should be out of date if the refresh interval has passed and an update is in progress', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - c2DomainBlocklistRefreshInterval: 10, - }); - jest.advanceTimersByTime(1000 * 10); - const pendingUpdate = controller.updateC2DomainBlocklist(); + it('should handle errors during hotlist fetching gracefully', async () => { + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; - expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); + nock(PHISHING_CONFIG_BASE_URL) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/0`) + .replyWithError('network error'); - // Cleanup pending operations - await pendingUpdate; - }); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [], + lastFetchedAt: 1, + }); - it('should not be out of date if the C2 domain blocklist was just updated', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ - c2DomainBlocklistRefreshInterval: 10, + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 1, + }, + ], + }, }); - await controller.updateC2DomainBlocklist(); - expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); + await controller.maybeUpdateState(); + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + name: ListNames.MetaMask, + version: 1, + lastUpdated: 1, + }, + ]); }); - it('should not be out of date if the C2 domain blocklist was recently updated', async () => { + it('should handle empty data during hotlist fetching gracefully', async () => { jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController({ - c2DomainBlocklistRefreshInterval: 10, - }); - await controller.updateC2DomainBlocklist(); - jest.advanceTimersByTime(1000 * 5); + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 0, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/0`) + .reply(200, { data: undefined }); - expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); - }); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}`) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); - it('should be out of date if the time elapsed since the last update equals the refresh interval', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ - c2DomainBlocklistRefreshInterval: 10, + hotlistRefreshInterval: 0, + stalelistRefreshInterval: 500000, + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + }, }); - await controller.updateC2DomainBlocklist(); - jest.advanceTimersByTime(1000 * 10); - expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); - }); - }); + await controller.maybeUpdateState(); - it('should return negative result for safe domain from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: ['metamask.io'], + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], blocklist: [], - blocklistPaths: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, + tolerance: 3, + name: ListNames.MetaMask, + version: 1, + lastUpdated: 0, }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); + ]); + }); - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, + it('should update the C2 domain blocklist if the fetch returns 200', async () => { + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + + // Mocking the request to the C2 domain blocklist endpoint + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + c2DomainBlocklistLastFetched: 0, + stalelistLastFetched: fetchTimeNow(), + }, }); - const controller = getPhishingController(); - await controller.updateStalelist(); - expect(controller.test(formatHostnameToUrl('metamask.io'))).toMatchObject({ - result: false, - type: PhishingDetectorResultType.Allowlist, - name: ListNames.MetaMask, - }); - }); + await controller.maybeUpdateState(); - it('should return negative result for safe unicode domain from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { + expect(controller.state.phishingLists).toStrictEqual([ + { allowlist: [], blocklist: [], - blocklistPaths: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); - - const controller = getPhishingController(); - await controller.updateStalelist(); - expect(controller.test(formatHostnameToUrl('i❤.ws'))).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, + ]); + expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); }); - }); - it('should return negative result for safe punycode domain from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: [], - blocklist: [], - blocklistPaths: [], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, + it('should not update the C2 domain blocklist if the fetch returns 404', async () => { + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(404); + + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + c2DomainBlocklistLastFetched: 0, }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); + }); - const controller = getPhishingController(); - await controller.updateStalelist(); - expect(controller.test(formatHostnameToUrl('xn--i-7iq.ws'))).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, - }); - }); + await controller.maybeUpdateState(); - it('should return positive result for unsafe domain from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { + expect(controller.state.phishingLists).toStrictEqual([ + { allowlist: [], - blocklist: ['etnerscan.io'], - blocklistPaths: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); + ]); + expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); + }); - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); + it('should update request blocklist with additions and removals', async () => { + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + const exampleRequestBlockedHashTwo = 'd3bkcslj57l47pamplifyapp'; - const controller = getPhishingController(); - await controller.updateStalelist(); - expect(controller.test(formatHostnameToUrl('etnerscan.io'))).toMatchObject({ - result: true, - type: PhishingDetectorResultType.Blocklist, - name: ListNames.MetaMask, - }); - }); + // Mock the request blocklist response with additions and removals + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [exampleRequestBlockedHashTwo], + lastFetchedAt: 1, + }); - it('should return positive result for unsafe unicode domain from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - blocklist: ['xn--myetherallet-4k5fwn.com'], - blocklistPaths: [], - allowlist: [], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, + // Initialize the controller with an existing state + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHashTwo], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + stalelistLastFetched: fetchTimeNow(), }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); - - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, }); - const controller = getPhishingController(); - await controller.updateStalelist(); - expect( - controller.test(formatHostnameToUrl('myetherẉalletṭ.com')), - ).toMatchObject({ - result: true, - type: PhishingDetectorResultType.Blocklist, - name: ListNames.MetaMask, - }); - }); + await controller.maybeUpdateState(); - it('should return positive result for unsafe punycode domain from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { + // Check the updated state + expect(controller.state.phishingLists).toStrictEqual([ + { allowlist: [], - blocklist: ['xn--myetherallet-4k5fwn.com'], - blocklistPaths: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, + tolerance: 3, + name: ListNames.MetaMask, + version: 1, + lastUpdated: 0, }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); + ]); + }); - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, + it('should handle empty recentlyAdded and recentlyRemoved in the response', async () => { + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + c2DomainBlocklistLastFetched: 0, + }, }); - const controller = getPhishingController(); - await controller.updateStalelist(); - expect( - controller.test(formatHostnameToUrl('xn--myetherallet-4k5fwn.com')), - ).toMatchObject({ - result: true, - type: PhishingDetectorResultType.Blocklist, - name: ListNames.MetaMask, - }); - }); + await controller.maybeUpdateState(); - it('should return positive result for unsafe unicode domain from the MetaMask hotlist (blocklist)', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { + expect(controller.state.phishingLists).toStrictEqual([ + { allowlist: [], blocklist: [], - blocklistPaths: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { - data: [ - { - url: 'e4d600ab9141b7a9859511c77e63b9b3.com', - timestamp: 2, - targetList: 'eth_phishing_detect_config.blocklist', - }, - ], - }); - - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); - - const controller = getPhishingController(); - await controller.updateStalelist(); - expect( - controller.test( - formatHostnameToUrl('e4d600ab9141b7a9859511c77e63b9b3.com'), - ), - ).toMatchObject({ - result: true, - type: PhishingDetectorResultType.Blocklist, - name: ListNames.MetaMask, - }); - }); - - it('should return negative result for unsafe unicode domain if the MetaMask hotlist (blocklist) returns 500', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: [], - blocklist: [], - blocklistPaths: [], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, - }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(500); - - const controller = getPhishingController(); - await controller.updateStalelist(); - expect( - controller.test( - formatHostnameToUrl('e4d600ab9141b7a9859511c77e63b9b3.com'), - ), - ).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, + ]); + expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); }); - }); - - it('should return negative result for safe fuzzylist domain from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: ['opensea.io'], - blocklist: [], - blocklistPaths: [], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, - }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); - - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); - const controller = getPhishingController(); - await controller.updateStalelist(); - expect(controller.test(formatHostnameToUrl('opensea.io'))).toMatchObject({ - result: false, - type: PhishingDetectorResultType.Allowlist, - name: ListNames.MetaMask, - }); - }); + it('should handle errors during C2 domain blocklist fetching gracefully', async () => { + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .replyWithError('network error'); - it('should return positive result for domain very close to fuzzylist from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: ['opensea.io'], - blocklist: [], - blocklistPaths: [], - fuzzylist: ['opensea.io'], - tolerance: 2, - version: 0, - lastUpdated: 1, + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + c2DomainBlocklistLastFetched: 0, }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); - - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, }); - const controller = getPhishingController(); - await controller.updateStalelist(); - expect(controller.test(formatHostnameToUrl('ohpensea.io'))).toMatchObject({ - result: true, - type: PhishingDetectorResultType.Fuzzy, - name: ListNames.MetaMask, - }); - }); - - it('should return negative result for domain not very close to fuzzylist from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: ['opensea.io'], - blocklist: [], - blocklistPaths: [], - fuzzylist: ['opensea.io'], - tolerance: 0, - version: 0, - lastUpdated: 1, - }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); - const controller = getPhishingController(); - await controller.updateStalelist(); - expect( - controller.test( - formatHostnameToUrl('this-is-the-official-website-of-opensea.io'), - ), - ).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, - }); - }); + await controller.maybeUpdateState(); - it('should bypass a given domain, and return a negative result', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { + expect(controller.state.phishingLists).toStrictEqual([ + { allowlist: [], - blocklist: ['electrum.mx'], - blocklistPaths: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], - tolerance: 2, - version: 0, - lastUpdated: 1, + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); - - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); - - const controller = getPhishingController(); - await controller.updateStalelist(); - const unsafeDomain = 'electrum.mx'; - assert.equal( - controller.test(formatHostnameToUrl(unsafeDomain)).result, - true, - 'Example unsafe domain seems to be safe', - ); - controller.bypass(formatHostnameToUrl(unsafeDomain)); - expect(controller.test(formatHostnameToUrl(unsafeDomain))).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, + ]); + expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); }); - }); - it('should ignore second attempt to bypass a domain, and still return a negative result', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: [], - blocklist: ['electrum.mx'], - blocklistPaths: [], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, - }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); - - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); + it('should not have stalelist be out of date immediately after maybeUpdateState is called', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); - const controller = getPhishingController(); - await controller.updateStalelist(); - const unsafeDomain = 'electrum.mx'; - assert.equal( - controller.test(formatHostnameToUrl(unsafeDomain)).result, - true, - 'Example unsafe domain seems to be safe', - ); - controller.bypass(formatHostnameToUrl(unsafeDomain)); - controller.bypass(formatHostnameToUrl(unsafeDomain)); - expect(controller.test(formatHostnameToUrl(unsafeDomain))).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, - }); - }); + const nockScope = nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + blocklist: ['this-should-not-be-in-default-blocklist.com'], + blocklistPaths: [], + fuzzylist: [], + allowlist: ['this-should-not-be-in-default-allowlist.com'], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { + data: [ + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 2, + isRemoval: true, + targetList: 'eth_phishing_detect_config.blocklist', + }, + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 3, + targetList: 'eth_phishing_detect_config.blocklist', + }, + ], + }); - it('should bypass a given unicode domain, and return a negative result', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: [], - blocklist: ['xn--myetherallet-4k5fwn.com'], - blocklistPaths: [], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, - }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, + const controller = getPhishingController({ + stalelistRefreshInterval: 10, }); + jest.advanceTimersByTime(1000 * 10); - const controller = getPhishingController(); - await controller.updateStalelist(); - const unsafeDomain = 'myetherẉalletṭ.com'; - assert.equal( - controller.test(formatHostnameToUrl(unsafeDomain)).result, - true, - 'Example unsafe domain seems to be safe', - ); - controller.bypass(formatHostnameToUrl(unsafeDomain)); - expect(controller.test(formatHostnameToUrl(unsafeDomain))).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, - }); - }); - - it('should bypass a given punycode domain, and return a negative result', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: [], - blocklist: ['xn--myetherallet-4k5fwn.com'], - blocklistPaths: [], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, - }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); + const stalelistLastFetchedBeforeUpdate = + controller.state.stalelistLastFetched; - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); + expect(isOutOfDate(stalelistLastFetchedBeforeUpdate, 10)).toBe(true); - const controller = getPhishingController(); - await controller.updateStalelist(); - const unsafeDomain = 'xn--myetherallet-4k5fwn.com'; - assert.equal( - controller.test(formatHostnameToUrl(unsafeDomain)).result, - true, - 'Example unsafe domain seems to be safe', - ); - controller.bypass(formatHostnameToUrl(unsafeDomain)); - expect(controller.test(formatHostnameToUrl(unsafeDomain))).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, - }); - }); + await controller.maybeUpdateState(); - it('returns positive result for unsafe hostname+pathname from MetaMask config', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: [], - blocklist: [], - blocklistPaths: ['example.com/path'], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, - }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); + const stalelistLastFetchedAfterUpdate = + controller.state.stalelistLastFetched; - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); + expect(isOutOfDate(stalelistLastFetchedAfterUpdate, 10)).toBe(false); - const controller = getPhishingController(); - await controller.updateStalelist(); - expect(controller.test('https://example.com/path')).toMatchObject({ - result: true, - type: PhishingDetectorResultType.Blocklist, + expect(nockScope.isDone()).toBe(true); }); - }); - it('returns negative result if the hostname+pathname is in the whitelistPaths', async () => { - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: { - 'example.com': { - path: {}, - }, - }, + it('should not be out of date after maybemaybeUpdateState is called but before refresh interval has passed', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + + const nockScope = nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + blocklist: ['this-should-not-be-in-default-blocklist.com'], + blocklistPaths: [], fuzzylist: [], + allowlist: ['this-should-not-be-in-default-allowlist.com'], tolerance: 0, version: 0, - lastUpdated: 0, - name: ListNames.MetaMask, + lastUpdated: 1, }, - ], - }, - }); - controller.bypass('https://example.com/path'); - expect(controller.test('https://example.com/path')).toMatchObject({ - result: false, - type: PhishingDetectorResultType.All, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { + data: [ + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 2, + isRemoval: true, + targetList: 'eth_phishing_detect_config.blocklist', + }, + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 3, + targetList: 'eth_phishing_detect_config.blocklist', + }, + ], + }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const controller = getPhishingController({ + stalelistRefreshInterval: 10, + }); + jest.advanceTimersByTime(1000 * 10); + + const stalelistLastFetchedBeforeUpdate = + controller.state.stalelistLastFetched; + + expect(isOutOfDate(stalelistLastFetchedBeforeUpdate, 10)).toBe(true); + + await controller.maybeUpdateState(); + + jest.advanceTimersByTime(1000 * 5); + + const stalelistLastFetchedAfterUpdate = + controller.state.stalelistLastFetched; + + expect(isOutOfDate(stalelistLastFetchedAfterUpdate, 10)).toBe(false); + + expect(nockScope.isDone()).toBe(true); }); - }); - it('returns positive result even if the hostname+pathname contains percent encoding', async () => { - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - blocklistPaths: { - 'example.com': { - path: {}, - }, - }, - c2DomainBlocklist: [], + it('should still be out of date while update is in progress', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + + const nockScope = nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + blocklist: ['this-should-not-be-in-default-blocklist.com'], + blocklistPaths: [], fuzzylist: [], + allowlist: ['this-should-not-be-in-default-allowlist.com'], tolerance: 0, version: 0, - lastUpdated: 0, - name: ListNames.MetaMask, + lastUpdated: 1, }, - ], - }, - }); + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { + data: [ + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 2, + isRemoval: true, + targetList: 'eth_phishing_detect_config.blocklist', + }, + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 3, + targetList: 'eth_phishing_detect_config.blocklist', + }, + ], + }); - expect(controller.test('https://example.com/%70%61%74%68')).toMatchObject({ - result: true, - type: PhishingDetectorResultType.Blocklist, + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const controller = getPhishingController({ + stalelistRefreshInterval: 10, + }); + jest.advanceTimersByTime(1000 * 10); + // do not wait + const maybeUpdatePhisingListPromise = controller.maybeUpdateState(); + + const stalelistLastFetchedBeforeUpdate = + controller.state.stalelistLastFetched; + + expect(isOutOfDate(stalelistLastFetchedBeforeUpdate, 10)).toBe(true); + + await maybeUpdatePhisingListPromise; + + const stalelistLastFetchedDuringUpdate = + controller.state.stalelistLastFetched; + + expect(isOutOfDate(stalelistLastFetchedDuringUpdate, 10)).toBe(false); + + jest.advanceTimersByTime(1000 * 10); + + const stalelistLastFetchedAfterUpdate = + controller.state.stalelistLastFetched; + + expect(isOutOfDate(stalelistLastFetchedAfterUpdate, 10)).toBe(true); + + expect(nockScope.isDone()).toBe(true); }); - }); - describe('updateStalelist', () => { - it('should update lists with addition to hotlist', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 2 }); - const exampleBlockedUrl = 'example-blocked-website.com'; - const exampleRequestBlockedHash = - '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; - const exampleBlockedUrlOne = - 'https://another-example-blocked-website.com'; - nock(PHISHING_CONFIG_BASE_URL) + it('should call update only if it is out of date, otherwise it should not call update', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + + const nockScope = nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - allowlist: [], - blocklist: [exampleBlockedUrl], + blocklist: ['this-should-not-be-in-default-blocklist.com'], blocklistPaths: [], fuzzylist: [], + allowlist: ['this-should-not-be-in-default-allowlist.com'], tolerance: 0, version: 0, lastUpdated: 1, @@ -1422,8 +1218,14 @@ describe('PhishingController', () => { .reply(200, { data: [ { - url: exampleBlockedUrlOne, + url: 'this-should-not-be-in-default-blocklist.com', timestamp: 2, + isRemoval: true, + targetList: 'eth_phishing_detect_config.blocklist', + }, + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 3, targetList: 'eth_phishing_detect_config.blocklist', }, ], @@ -1432,43 +1234,119 @@ describe('PhishingController', () => { nock(CLIENT_SIDE_DETECION_BASE_URL) .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) .reply(200, { - recentlyAdded: [exampleRequestBlockedHash], + recentlyAdded: [], recentlyRemoved: [], lastFetchedAt: 1, }); - const controller = getPhishingController(); - await controller.updateStalelist(); + const controller = getPhishingController({ + stalelistRefreshInterval: 10, + }); + const { stalelistLastFetched } = controller.state; - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], - blocklist: [exampleBlockedUrl, exampleBlockedUrlOne], - c2DomainBlocklist: [exampleRequestBlockedHash], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 0, - lastUpdated: 2, - name: ListNames.MetaMask, - version: 0, - }, - ]); + expect(isOutOfDate(stalelistLastFetched, 10)).toBe(false); + + await controller.maybeUpdateState(); + + expect( + controller.test( + formatHostnameToUrl('this-should-not-be-in-default-blocklist.com'), + ), + ).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + + expect( + controller.test( + formatHostnameToUrl('this-should-not-be-in-default-allowlist.com'), + ), + ).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + + jest.advanceTimersByTime(1000 * 10); + await controller.maybeUpdateState(); + + expect( + controller.test( + formatHostnameToUrl('this-should-not-be-in-default-blocklist.com'), + ), + ).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, + }); + + expect( + controller.test( + formatHostnameToUrl('this-should-not-be-in-default-allowlist.com'), + ), + ).toMatchObject({ + result: false, + type: PhishingDetectorResultType.Allowlist, + }); + + expect(nockScope.isDone()).toBe(true); + }); + + it('should not have hotlist be out of date immediately after maybeUpdateState is called', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { + data: [ + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 1, + isRemoval: true, + targetList: 'eth_phishing_detect_config.blocklist', + }, + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 2, + targetList: 'eth_phishing_detect_config.blocklist', + }, + ], + }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: 50, + }); + const controller = getPhishingController({ + hotlistRefreshInterval: 10, + stalelistRefreshInterval: 50, + }); + jest.advanceTimersByTime(1000 * 10); + + const hotlistLastFetchedBeforeUpdate = + controller.state.hotlistLastFetched; + + expect(isOutOfDate(hotlistLastFetchedBeforeUpdate, 10)).toBe(true); + + await controller.maybeUpdateState(); + + const hotlistLastFetchedAfterUpdate = controller.state.hotlistLastFetched; + + expect(isOutOfDate(hotlistLastFetchedAfterUpdate, 10)).toBe(false); }); - it('should update lists with removal diff from hotlist', async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 2 }); - const exampleBlockedUrl = 'example-blocked-website.com'; - const exampleRequestBlockedHash = - '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; - const exampleBlockedUrlTwo = 'another-example-blocked-website.com'; + it('should not have c2DomainBlocklist be out of date immediately after maybeUpdateState is called', async () => { nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - allowlist: [], - blocklist: [exampleBlockedUrl], + blocklist: ['this-should-not-be-in-default-blocklist.com'], blocklistPaths: [], fuzzylist: [], + allowlist: ['this-should-not-be-in-default-allowlist.com'], tolerance: 0, version: 0, lastUpdated: 1, @@ -1478,15 +1356,15 @@ describe('PhishingController', () => { .reply(200, { data: [ { - url: exampleBlockedUrlTwo, + url: 'this-should-not-be-in-default-blocklist.com', timestamp: 2, + isRemoval: true, targetList: 'eth_phishing_detect_config.blocklist', }, { - url: exampleBlockedUrl, - timestamp: 2, + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 3, targetList: 'eth_phishing_detect_config.blocklist', - isRemoval: true, }, ], }); @@ -1494,265 +1372,196 @@ describe('PhishingController', () => { nock(CLIENT_SIDE_DETECION_BASE_URL) .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) .reply(200, { - recentlyAdded: [exampleRequestBlockedHash], + recentlyAdded: [], recentlyRemoved: [], lastFetchedAt: 1, }); - const controller = getPhishingController(); - await controller.updateStalelist(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const controller = getPhishingController({ + c2DomainBlocklistRefreshInterval: 10, + }); + jest.advanceTimersByTime(1000 * 10); - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], - blocklist: [exampleBlockedUrlTwo], - c2DomainBlocklist: [exampleRequestBlockedHash], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 2, - name: ListNames.MetaMask, - }, - ]); - }); + const { c2DomainBlocklistLastFetched } = controller.state; - it('should correctly process blocklist entries with paths into blocklistPaths', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(200, { - data: { - allowlist: [], - blocklist: ['example.com'], - blocklistPaths: ['malicious.com/phishing'], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, - }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(200, { data: [] }); + expect(isOutOfDate(c2DomainBlocklistLastFetched, 10)).toBe(true); + await controller.maybeUpdateState(); - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); + const { + c2DomainBlocklistLastFetched: c2DomainBlocklistLastFetchedAfterUpdate, + } = controller.state; - const controller = getPhishingController(); - await controller.updateStalelist(); - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], - blocklist: ['example.com'], - c2DomainBlocklist: [], - blocklistPaths: { - 'malicious.com': { - phishing: {}, - }, - }, - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, - name: ListNames.MetaMask, - }, - ]); + expect(isOutOfDate(c2DomainBlocklistLastFetchedAfterUpdate, 10)).toBe( + false, + ); }); - it('should not update phishing lists if fetch returns 304', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .reply(304) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(304); - - const controller = getPhishingController({ + it('replaces existing phishing lists with completely new list from phishing detection API', async () => { + const { messenger } = setupMessenger(); + const controller = new PhishingController({ + messenger, + stalelistRefreshInterval: 10, state: { phishingLists: [ { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], + allowlist: ['initial-safe-site.com'], + blocklist: ['new-phishing-site.com'], blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, + c2DomainBlocklist: [], + fuzzylist: ['new-fuzzy-site.com'], + tolerance: 2, version: 1, + lastUpdated: 1, name: ListNames.MetaMask, - lastUpdated: 0, }, ], + whitelist: [], + whitelistPaths: {}, + hotlistLastFetched: 0, + stalelistLastFetched: 0, + c2DomainBlocklistLastFetched: 0, + urlScanCache: {}, }, }); - await controller.updateStalelist(); - - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ]); - }); - it('should not update phishing lists if fetch returns 500', async () => { nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) - .reply(500) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .reply(500); - + .reply(200, { + data: { + blocklist: [], + blocklistPaths: ['example.com/path'], + fuzzylist: ['new-fuzzy-site.com'], + allowlist: ['new-safe-site.com'], + tolerance: 2, + version: 2, + lastUpdated: 2, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${2}`) + .reply(200, { + data: [], + }); nock(CLIENT_SIDE_DETECION_BASE_URL) .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .reply(500); + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 2, + }); - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], - }, - }); - await controller.updateStalelist(); + // Force the stalelist to be out of date and trigger update + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + jest.advanceTimersByTime(1000 * 10); + + await controller.maybeUpdateState(); expect(controller.state.phishingLists).toStrictEqual([ { - allowlist: [], + allowlist: ['new-safe-site.com'], blocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, + fuzzylist: ['new-fuzzy-site.com'], + tolerance: 2, + version: 2, + lastUpdated: 2, name: ListNames.MetaMask, - lastUpdated: 0, }, ]); + + jest.useRealTimers(); }); - it('should not throw when there is a network error', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .replyWithError('network error') + it('should not re-request a hotlist update when an update is in progress', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const nockScope = nock(PHISHING_CONFIG_BASE_URL) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .replyWithError('network error'); + .delay(500) // delay promise resolution to generate "pending" state that lasts long enough to test. + .reply(200, { + data: [ + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 1, + isRemoval: true, + targetList: 'eth_phishing_detect_config.blocklist', + }, + { + url: 'this-should-not-be-in-default-blocklist.com', + timestamp: 2, + targetList: 'eth_phishing_detect_config.blocklist', + }, + ], + }); nock(CLIENT_SIDE_DETECION_BASE_URL) .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) - .replyWithError('network error'); - - const controller = getPhishingController(); - - expect(await controller.updateStalelist()).toBeUndefined(); - }); - - describe('an update is in progress', () => { - it('should not fetch phishing lists again', async () => { - jest.useFakeTimers({ - doNotFake: ['nextTick', 'queueMicrotask'], - now: 0, - }); - const nockScope = nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .delay(100) - .reply(200, { - data: { - allowlist: [], - blocklist: [], - fuzzylist: [], - tolerance: 0, - version: 0, - lastUpdated: 1, - }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .delay(100) - .reply(200, { data: [] }); - - const controller = getPhishingController(); - const firstPromise = controller.updateStalelist(); - const secondPromise = controller.updateStalelist(); - - jest.advanceTimersByTime(1000 * 100); - - await firstPromise; - await secondPromise; - - // This second update would throw if it fetched, because the - // nock interceptor was not persisted. - expect(nockScope.isDone()).toBe(true); - }); - - it('should wait until the in-progress update has completed', async () => { - jest.useFakeTimers({ - doNotFake: ['nextTick', 'queueMicrotask'], - now: 0, + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); - nock(PHISHING_CONFIG_BASE_URL) - .get(METAMASK_STALELIST_FILE) - .delay(100) - .reply(200, { - data: { + + const controller = getPhishingController({ + hotlistRefreshInterval: 10, + state: { + phishingLists: [ + { allowlist: [], blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 0, - version: 0, lastUpdated: 1, + name: ListNames.MetaMask, + version: 0, }, - }) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) - .delay(100) - .reply(200, { data: [] }); - - const controller = getPhishingController(); - const firstPromise = controller.updateStalelist(); - const secondPromise = controller.updateStalelist(); - jest.advanceTimersByTime(1000 * 99); + ], + }, + }); + jest.advanceTimersByTime(1000 * 10); + const pendingUpdate = controller.maybeUpdateState(); - await expect(secondPromise).toNeverResolve(); + const pendingUpdateTwo = controller.maybeUpdateState(); + expect(nockScope.activeMocks()).toHaveLength(1); - // Cleanup pending operations - await firstPromise; - await secondPromise; - }); + // Cleanup pending operations + await pendingUpdate; + await pendingUpdateTwo; }); - }); - describe('updateHotlist', () => { - it('should update phishing lists if hotlist fetch returns 200', async () => { - const testBlockedDomain = 'some-test-blocked-url.com'; - nock(PHISHING_CONFIG_BASE_URL) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${0}`) + + it('should not re-request a stalelist update when an update is in progress', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const nockScope = nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .delay(500) // delay promise resolution to generate "pending" state that lasts long enough to test. .reply(200, { - data: [ - { - targetList: 'eth_phishing_detect_config.blocklist', - url: testBlockedDomain, - timestamp: 1, - }, - ], + data: { + blocklist: [], + blocklistPaths: ['example.com/path'], + fuzzylist: ['new-fuzzy-site.com'], + allowlist: ['new-safe-site.com'], + tolerance: 2, + version: 2, + lastUpdated: 2, + }, + }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); const controller = getPhishingController({ + stalelistRefreshInterval: 10, state: { phishingLists: [ { @@ -1761,37 +1570,38 @@ describe('PhishingController', () => { c2DomainBlocklist: [], blocklistPaths: {}, fuzzylist: [], - tolerance: 3, - version: 1, + tolerance: 0, + lastUpdated: 1, name: ListNames.MetaMask, - lastUpdated: 0, + version: 0, }, ], }, }); - await controller.updateHotlist(); + jest.advanceTimersByTime(1000 * 10); + const pendingUpdate = controller.maybeUpdateState(); - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], - blocklist: [testBlockedDomain], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - name: ListNames.MetaMask, - version: 1, - lastUpdated: 1, - }, - ]); + const pendingUpdateTwo = controller.maybeUpdateState(); + expect(nockScope.activeMocks()).toHaveLength(1); + + // Cleanup pending operations + await pendingUpdate; + await pendingUpdateTwo; }); - it('should not update phishing lists if hotlist fetch returns 404', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${0}`) - .reply(404); + it('should not re-request a C2DomainBlocklist update when an update is in progress', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const nockScope = nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .delay(500) // delay promise resolution to generate "pending" state that lasts long enough to test. + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 2, + }); const controller = getPhishingController({ + c2DomainBlocklistRefreshInterval: 10, state: { phishingLists: [ { @@ -1800,483 +1610,636 @@ describe('PhishingController', () => { c2DomainBlocklist: [], blocklistPaths: {}, fuzzylist: [], - tolerance: 3, - version: 1, + tolerance: 0, + lastUpdated: 1, name: ListNames.MetaMask, - lastUpdated: 0, + version: 0, }, ], }, }); - await controller.updateHotlist(); + jest.advanceTimersByTime(1000 * 10); + const pendingUpdate = controller.maybeUpdateState(); + + const pendingUpdateTwo = controller.maybeUpdateState(); + expect(nockScope.activeMocks()).toHaveLength(1); + + // Cleanup pending operations + await pendingUpdate; + await pendingUpdateTwo; + }); + }); + + it('should return negative result for safe domain from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: ['metamask.io'], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect(controller.test(formatHostnameToUrl('metamask.io'))).toMatchObject({ + result: false, + type: PhishingDetectorResultType.Allowlist, + name: ListNames.MetaMask, + }); + }); + + it('should return negative result for safe unicode domain from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect(controller.test(formatHostnameToUrl('i❤.ws'))).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); + + it('should return negative result for safe punycode domain from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect(controller.test(formatHostnameToUrl('xn--i-7iq.ws'))).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); + + it('should return positive result for unsafe domain from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: ['etnerscan.io'], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect(controller.test(formatHostnameToUrl('etnerscan.io'))).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, + name: ListNames.MetaMask, + }); + }); + + it('should return positive result for unsafe unicode domain from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], + allowlist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); - expect(controller.state.phishingLists).toStrictEqual([ - { - ...controller.state.phishingLists[0], - lastUpdated: 0, - }, - ]); + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect( + controller.test(formatHostnameToUrl('myetherẉalletṭ.com')), + ).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, + name: ListNames.MetaMask, }); + }); - it('should not make API calls to update hotlist when phishingLists array is empty', async () => { - const testBlockedDomain = 'some-test-blocked-url.com'; - const hotlistNock = nock(PHISHING_CONFIG_BASE_URL) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/${0}`) - .reply(200, { - data: [ - { - targetList: 'eth_phishing_detect_config.blocklist', - url: testBlockedDomain, - timestamp: 1, - }, - ], - }); - - const controller = getPhishingController({ - state: { - phishingLists: [], + it('should return positive result for unsafe punycode domain from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); - await controller.updateHotlist(); - expect(hotlistNock.isDone()).toBe(false); + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect( + controller.test(formatHostnameToUrl('xn--myetherallet-4k5fwn.com')), + ).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, + name: ListNames.MetaMask, }); + }); - it('should handle empty hotlist and request blocklist responses gracefully', async () => { - nock(PHISHING_CONFIG_BASE_URL) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/0`) - .reply(200, { data: [] }); - - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); - - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], - }, - }); - await controller.updateHotlist(); - await controller.updateC2DomainBlocklist(); - - expect(controller.state.phishingLists).toStrictEqual([ - { + it('should return positive result for unsafe unicode domain from the MetaMask hotlist (blocklist)', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { allowlist: [], blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, + blocklistPaths: [], fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, + tolerance: 0, + version: 0, + lastUpdated: 1, }, - ]); - }); - - it('should handle errors during hotlist fetching gracefully', async () => { - const exampleRequestBlockedHash = - '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; - - nock(PHISHING_CONFIG_BASE_URL) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/0`) - .replyWithError('network error'); - - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) - .reply(200, { - recentlyAdded: [exampleRequestBlockedHash], - recentlyRemoved: [], - lastFetchedAt: 1, - }); + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { + data: [ + { + url: 'e4d600ab9141b7a9859511c77e63b9b3.com', + timestamp: 2, + targetList: 'eth_phishing_detect_config.blocklist', + }, + ], + }); - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [exampleRequestBlockedHash], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 1, - }, - ], - }, + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); - await controller.updateHotlist(); - await controller.updateC2DomainBlocklist(); + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect( + controller.test( + formatHostnameToUrl('e4d600ab9141b7a9859511c77e63b9b3.com'), + ), + ).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, + name: ListNames.MetaMask, + }); + }); - expect(controller.state.phishingLists).toStrictEqual([ - { + it('should return negative result for unsafe unicode domain if the MetaMask hotlist (blocklist) returns 500', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { allowlist: [], blocklist: [], - c2DomainBlocklist: [exampleRequestBlockedHash], - blocklistPaths: {}, + blocklistPaths: [], fuzzylist: [], - tolerance: 3, - name: ListNames.MetaMask, - version: 1, + tolerance: 0, + version: 0, lastUpdated: 1, }, - ]); + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(500); + + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect( + controller.test( + formatHostnameToUrl('e4d600ab9141b7a9859511c77e63b9b3.com'), + ), + ).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, }); - it('should handle missing hotlist data and non-empty domain blocklist gracefully', async () => { - const exampleRequestBlockedHash = - '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + }); - nock(PHISHING_CONFIG_BASE_URL) - .get(`${METAMASK_HOTLIST_DIFF_FILE}/0`) - .reply(500); + it('should return negative result for safe fuzzylist domain from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: ['opensea.io'], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) - .reply(200, { - recentlyAdded: [exampleRequestBlockedHash], - recentlyRemoved: [], - lastFetchedAt: 1, - }); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect(controller.test(formatHostnameToUrl('opensea.io'))).toMatchObject({ + result: false, + type: PhishingDetectorResultType.Allowlist, + name: ListNames.MetaMask, + }); + }); + + it('should return positive result for domain very close to fuzzylist from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: ['opensea.io'], + blocklist: [], + blocklistPaths: [], + fuzzylist: ['opensea.io'], + tolerance: 2, + version: 0, + lastUpdated: 1, }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); - await controller.updateHotlist(); - await controller.updateC2DomainBlocklist(); + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect(controller.test(formatHostnameToUrl('ohpensea.io'))).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Fuzzy, + name: ListNames.MetaMask, + }); + }); - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], + it('should return negative result for domain not very close to fuzzylist from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: ['opensea.io'], blocklist: [], - c2DomainBlocklist: [exampleRequestBlockedHash], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - name: ListNames.MetaMask, - version: 1, - lastUpdated: 0, + blocklistPaths: [], + fuzzylist: ['opensea.io'], + tolerance: 0, + version: 0, + lastUpdated: 1, }, - ]); + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect( + controller.test( + formatHostnameToUrl('this-is-the-official-website-of-opensea.io'), + ), + ).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, }); }); - describe('updateC2DomainBlocklist', () => { - it('should update the C2 domain blocklist if the fetch returns 200', async () => { - const exampleRequestBlockedHash = - '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; - - // Mocking the request to the C2 domain blocklist endpoint - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) - .reply(200, { - recentlyAdded: [exampleRequestBlockedHash], - recentlyRemoved: [], - lastFetchedAt: 1, - }); - - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], - c2DomainBlocklistLastFetched: 0, + it('should bypass a given domain, and return a negative result', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: ['electrum.mx'], + blocklistPaths: [], + fuzzylist: [], + tolerance: 2, + version: 0, + lastUpdated: 1, }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); - await controller.updateC2DomainBlocklist(); + const controller = getPhishingController(); + await controller.maybeUpdateState(); + const unsafeDomain = 'electrum.mx'; + assert.equal( + controller.test(formatHostnameToUrl(unsafeDomain)).result, + true, + 'Example unsafe domain seems to be safe', + ); + controller.bypass(formatHostnameToUrl(unsafeDomain)); + expect(controller.test(formatHostnameToUrl(unsafeDomain))).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); - expect(controller.state.phishingLists).toStrictEqual([ - { + it('should ignore second attempt to bypass a domain, and still return a negative result', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { allowlist: [], - blocklist: [], - c2DomainBlocklist: [exampleRequestBlockedHash], - blocklistPaths: {}, + blocklist: ['electrum.mx'], + blocklistPaths: [], fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, + tolerance: 0, + version: 0, + lastUpdated: 1, }, - ]); - expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); - }); - - it('should not update the C2 domain blocklist if the fetch returns 404', async () => { - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) - .reply(404); + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], - c2DomainBlocklistLastFetched: 0, - }, + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); - await controller.updateC2DomainBlocklist(); + const controller = getPhishingController(); + await controller.maybeUpdateState(); + const unsafeDomain = 'electrum.mx'; + assert.equal( + controller.test(formatHostnameToUrl(unsafeDomain)).result, + true, + 'Example unsafe domain seems to be safe', + ); + controller.bypass(formatHostnameToUrl(unsafeDomain)); + controller.bypass(formatHostnameToUrl(unsafeDomain)); + expect(controller.test(formatHostnameToUrl(unsafeDomain))).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); - expect(controller.state.phishingLists).toStrictEqual([ - { + it('should bypass a given unicode domain, and return a negative result', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, + blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, + tolerance: 0, + version: 0, + lastUpdated: 1, }, - ]); - expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); - }); - - it('should update request blocklist with additions and removals', async () => { - const exampleRequestBlockedHash = - '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; - const exampleRequestBlockedHashTwo = 'd3bkcslj57l47pamplifyapp'; - - // Mock the request blocklist response with additions and removals - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) - .reply(200, { - recentlyAdded: [exampleRequestBlockedHash], - recentlyRemoved: [exampleRequestBlockedHashTwo], - lastFetchedAt: 1, - }); + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); - // Initialize the controller with an existing state - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [exampleRequestBlockedHashTwo], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], - }, + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); - await controller.updateC2DomainBlocklist(); + const controller = getPhishingController(); + await controller.maybeUpdateState(); + const unsafeDomain = 'myetherẉalletṭ.com'; + assert.equal( + controller.test(formatHostnameToUrl(unsafeDomain)).result, + true, + 'Example unsafe domain seems to be safe', + ); + controller.bypass(formatHostnameToUrl(unsafeDomain)); + expect(controller.test(formatHostnameToUrl(unsafeDomain))).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); - // Check the updated state - expect(controller.state.phishingLists).toStrictEqual([ - { + it('should bypass a given punycode domain, and return a negative result', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { allowlist: [], - blocklist: [], - c2DomainBlocklist: [exampleRequestBlockedHash], - blocklistPaths: {}, + blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], fuzzylist: [], - tolerance: 3, - name: ListNames.MetaMask, - version: 1, - lastUpdated: 0, + tolerance: 0, + version: 0, + lastUpdated: 1, }, - ]); - }); - - it('should handle an update that is already in progress', async () => { - const exampleRequestBlockedHash = - '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; - - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) - .reply(200, { - recentlyAdded: [exampleRequestBlockedHash], - recentlyRemoved: [], - lastFetchedAt: 1, - }); + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], - c2DomainBlocklistLastFetched: 0, - }, + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); - const firstUpdatePromise = controller.updateC2DomainBlocklist(); - const secondUpdatePromise = controller.updateC2DomainBlocklist(); - - await firstUpdatePromise; - await secondUpdatePromise; + const controller = getPhishingController(); + await controller.maybeUpdateState(); + const unsafeDomain = 'xn--myetherallet-4k5fwn.com'; + assert.equal( + controller.test(formatHostnameToUrl(unsafeDomain)).result, + true, + 'Example unsafe domain seems to be safe', + ); + controller.bypass(formatHostnameToUrl(unsafeDomain)); + expect(controller.test(formatHostnameToUrl(unsafeDomain))).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); - expect(controller.state.phishingLists).toStrictEqual([ - { + it('returns positive result for unsafe hostname+pathname from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { allowlist: [], blocklist: [], - c2DomainBlocklist: [exampleRequestBlockedHash], - blocklistPaths: {}, + blocklistPaths: ['example.com/path'], fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, + tolerance: 0, + version: 0, + lastUpdated: 1, }, - ]); - expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); - }); - - it('should handle empty recentlyAdded and recentlyRemoved in the response', async () => { - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) - .reply(200, { - recentlyAdded: [], - recentlyRemoved: [], - lastFetchedAt: 1, - }); + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], - c2DomainBlocklistLastFetched: 0, - }, + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); - await controller.updateC2DomainBlocklist(); - - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ]); - expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); + const controller = getPhishingController(); + await controller.maybeUpdateState(); + expect(controller.test('https://example.com/path')).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, }); + }); - it('should handle errors during C2 domain blocklist fetching gracefully', async () => { - nock(CLIENT_SIDE_DETECION_BASE_URL) - .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) - .replyWithError('network error'); - - const controller = getPhishingController({ - state: { - phishingLists: [ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, + it('returns negative result if the hostname+pathname is in the whitelistPaths', async () => { + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, }, - ], - c2DomainBlocklistLastFetched: 0, - }, - }); + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 0, + name: ListNames.MetaMask, + }, + ], + }, + }); + controller.bypass('https://example.com/path'); + expect(controller.test('https://example.com/path')).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); - await controller.updateC2DomainBlocklist(); + it('returns positive result even if the hostname+pathname contains percent encoding', async () => { + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + c2DomainBlocklist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 0, + name: ListNames.MetaMask, + }, + ], + }, + }); - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], - blocklist: [], - c2DomainBlocklist: [], - blocklistPaths: {}, - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ]); - expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); + expect(controller.test('https://example.com/%70%61%74%68')).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, }); }); @@ -2310,7 +2273,7 @@ describe('PhishingController', () => { }); const controller = getPhishingController(); - await controller.updateStalelist(); + await controller.maybeUpdateState(); const result = controller.isBlockedRequest('https://example.com'); expect(result).toMatchObject({ result: false, @@ -2345,7 +2308,7 @@ describe('PhishingController', () => { }); const controller = getPhishingController(); - await controller.updateStalelist(); + await controller.maybeUpdateState(); const result = controller.isBlockedRequest( 'https://develop.d3bkcslj57l47p.amplifyapp.com', ); @@ -2381,7 +2344,7 @@ describe('PhishingController', () => { }); const controller = getPhishingController(); - await controller.updateStalelist(); + await controller.maybeUpdateState(); const result = controller.isBlockedRequest('https://example.com'); expect(result).toMatchObject({ result: false, @@ -2414,7 +2377,7 @@ describe('PhishingController', () => { }); const controller = getPhishingController(); - await controller.updateStalelist(); + await controller.maybeUpdateState(); const result = controller.isBlockedRequest('#$@(%&@#$(%'); expect(result).toMatchObject({ result: false, @@ -2464,7 +2427,7 @@ describe('PhishingController', () => { }); const controller = getPhishingController(); - await controller.updateStalelist(); + await controller.maybeUpdateState(); const result = controller.isBlockedRequest( `https://${allowlistedDomain}/path`, ); @@ -2474,6 +2437,7 @@ describe('PhishingController', () => { type: PhishingDetectorResultType.Allowlist, }); }); + describe('bypass', () => { let controller: PhishingController; diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 7cb8158e851..ae386ebecb3 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -20,6 +20,7 @@ import { CacheManager } from './CacheManager'; import type { CacheEntry } from './CacheManager'; import { convertListToTrie, insertToTrie, matchedPathPrefix } from './PathTrie'; import type { PathTrie } from './PathTrie'; +import type { PhishingControllerMethodActions } from './PhishingController-method-action-types'; import { PhishingDetector } from './PhishingDetector'; import { PhishingDetectorResultType, @@ -374,30 +375,16 @@ export type PhishingControllerOptions = { state?: Partial; }; -export type MaybeUpdateState = { - type: `${typeof controllerName}:maybeUpdateState`; - handler: PhishingController['maybeUpdateState']; -}; - -export type TestOrigin = { - type: `${typeof controllerName}:testOrigin`; - handler: PhishingController['test']; -}; - -export type PhishingControllerBulkScanUrlsAction = { - type: `${typeof controllerName}:bulkScanUrls`; - handler: PhishingController['bulkScanUrls']; -}; - -export type PhishingControllerBulkScanTokensAction = { - type: `${typeof controllerName}:bulkScanTokens`; - handler: PhishingController['bulkScanTokens']; -}; - -export type PhishingControllerScanAddressAction = { - type: `${typeof controllerName}:scanAddress`; - handler: PhishingController['scanAddress']; -}; +export const MESSENGER_EXPOSED_METHODS = [ + 'maybeUpdateState', + 'test', + 'isBlockedRequest', + 'bypass', + 'scanUrl', + 'bulkScanUrls', + 'bulkScanTokens', + 'scanAddress', +] as const; export type PhishingControllerGetStateAction = ControllerGetStateAction< typeof controllerName, @@ -406,11 +393,7 @@ export type PhishingControllerGetStateAction = ControllerGetStateAction< export type PhishingControllerActions = | PhishingControllerGetStateAction - | MaybeUpdateState - | TestOrigin - | PhishingControllerBulkScanUrlsAction - | PhishingControllerBulkScanTokensAction - | PhishingControllerScanAddressAction; + | PhishingControllerMethodActions; export type PhishingControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, @@ -558,9 +541,12 @@ export class PhishingController extends BaseController< }, }); - this.#registerMessageHandlers(); + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); - this.updatePhishingDetector(); + this.#updatePhishingDetector(); this.#subscribeToTransactionControllerStateChange(); } @@ -571,37 +557,6 @@ export class PhishingController extends BaseController< ); } - /** - * Constructor helper for registering this controller's messaging system - * actions. - */ - #registerMessageHandlers(): void { - this.messenger.registerActionHandler( - `${controllerName}:maybeUpdateState` as const, - this.maybeUpdateState.bind(this), - ); - - this.messenger.registerActionHandler( - `${controllerName}:testOrigin` as const, - this.test.bind(this), - ); - - this.messenger.registerActionHandler( - `${controllerName}:bulkScanUrls` as const, - this.bulkScanUrls.bind(this), - ); - - this.messenger.registerActionHandler( - `${controllerName}:bulkScanTokens` as const, - this.bulkScanTokens.bind(this), - ); - - this.messenger.registerActionHandler( - `${controllerName}:scanAddress` as const, - this.scanAddress.bind(this), - ); - } - /** * Checks if a patch represents a transaction-level change or nested transaction property change * @@ -724,7 +679,7 @@ export class PhishingController extends BaseController< /** * Updates this.detector with an instance of PhishingDetector using the current state. */ - updatePhishingDetector() { + #updatePhishingDetector() { this.#detector = new PhishingDetector(this.state.phishingLists); } @@ -733,7 +688,7 @@ export class PhishingController extends BaseController< * * @returns Whether an update is needed */ - isStalelistOutOfDate() { + #isStalelistOutOfDate() { return ( fetchTimeNow() - this.state.stalelistLastFetched >= this.#stalelistRefreshInterval @@ -745,7 +700,7 @@ export class PhishingController extends BaseController< * * @returns Whether an update is needed */ - isHotlistOutOfDate() { + #isHotlistOutOfDate() { return ( fetchTimeNow() - this.state.hotlistLastFetched >= this.#hotlistRefreshInterval @@ -757,7 +712,7 @@ export class PhishingController extends BaseController< * * @returns Whether an update is needed */ - isC2DomainBlocklistOutOfDate() { + #isC2DomainBlocklistOutOfDate() { return ( fetchTimeNow() - this.state.c2DomainBlocklistLastFetched >= this.#c2DomainBlocklistRefreshInterval @@ -767,24 +722,24 @@ export class PhishingController extends BaseController< /** * Conditionally update the phishing configuration. * - * If the stalelist configuration is out of date, this function will call `updateStalelist` + * If the stalelist configuration is out of date, this function will call `#updateStalelist` * to update the configuration. This will automatically grab the hotlist, * so it isn't necessary to continue on to download the hotlist and the c2 domain blocklist. * */ async maybeUpdateState() { - const staleListOutOfDate = this.isStalelistOutOfDate(); + const staleListOutOfDate = this.#isStalelistOutOfDate(); if (staleListOutOfDate) { - await this.updateStalelist(); + await this.#updateStalelist(); return; } - const hotlistOutOfDate = this.isHotlistOutOfDate(); + const hotlistOutOfDate = this.#isHotlistOutOfDate(); if (hotlistOutOfDate) { - await this.updateHotlist(); + await this.#updateHotlist(); } - const c2DomainBlocklistOutOfDate = this.isC2DomainBlocklistOutOfDate(); + const c2DomainBlocklistOutOfDate = this.#isC2DomainBlocklistOutOfDate(); if (c2DomainBlocklistOutOfDate) { - await this.updateC2DomainBlocklist(); + await this.#updateC2DomainBlocklist(); } } @@ -793,7 +748,7 @@ export class PhishingController extends BaseController< * * It is strongly recommended that you call {@link maybeUpdateState} before calling this, * to check whether the phishing configuration is up-to-date. It will be updated if necessary - * by calling {@link updateStalelist} or {@link updateHotlist}. + * by calling {@link #updateStalelist} or {@link #updateHotlist}. * * @param origin - Domain origin of a website. * @returns Whether the origin is an unapproved origin. @@ -869,14 +824,15 @@ export class PhishingController extends BaseController< * If an update is in progress, no additional update will be made. Instead this will wait until * the in-progress update has finished. */ - async updateC2DomainBlocklist() { + async #updateC2DomainBlocklist() { if (this.#isProgressC2DomainBlocklistUpdate) { await this.#isProgressC2DomainBlocklistUpdate; return; } try { - this.#isProgressC2DomainBlocklistUpdate = this.#updateC2DomainBlocklist(); + this.#isProgressC2DomainBlocklistUpdate = + this.#_updateC2DomainBlocklist(); await this.#isProgressC2DomainBlocklistUpdate; } finally { this.#isProgressC2DomainBlocklistUpdate = undefined; @@ -889,14 +845,14 @@ export class PhishingController extends BaseController< * If an update is in progress, no additional update will be made. Instead this will wait until * the in-progress update has finished. */ - async updateHotlist() { + async #updateHotlist() { if (this.#inProgressHotlistUpdate) { await this.#inProgressHotlistUpdate; return; } try { - this.#inProgressHotlistUpdate = this.#updateHotlist(); + this.#inProgressHotlistUpdate = this.#_updateHotlist(); await this.#inProgressHotlistUpdate; } finally { this.#inProgressHotlistUpdate = undefined; @@ -909,14 +865,14 @@ export class PhishingController extends BaseController< * If an update is in progress, no additional update will be made. Instead this will wait until * the in-progress update has finished. */ - async updateStalelist() { + async #updateStalelist() { if (this.#inProgressStalelistUpdate) { await this.#inProgressStalelistUpdate; return; } try { - this.#inProgressStalelistUpdate = this.#updateStalelist(); + this.#inProgressStalelistUpdate = this.#_updateStalelist(); await this.#inProgressStalelistUpdate; } finally { this.#inProgressStalelistUpdate = undefined; @@ -930,7 +886,7 @@ export class PhishingController extends BaseController< * @param url - The URL to scan. * @returns The phishing detection scan result. */ - scanUrl = async (url: string): Promise => { + async scanUrl(url: string): Promise { const [hostname, ok] = getHostnameFromWebUrl(url); if (!ok) { return { @@ -991,7 +947,7 @@ export class PhishingController extends BaseController< this.#urlScanCache.set(hostname, result); return result; - }; + } /** * Scan multiple URLs for phishing in bulk. It will only scan the hostnames of the URLs. @@ -1000,9 +956,9 @@ export class PhishingController extends BaseController< * @param urls - The URLs to scan. * @returns A mapping of URLs to their phishing detection scan results and errors. */ - bulkScanUrls = async ( + async bulkScanUrls( urls: string[], - ): Promise => { + ): Promise { if (!urls || urls.length === 0) { return { results: {}, @@ -1095,7 +1051,7 @@ export class PhishingController extends BaseController< } return combinedResponse; - }; + } /** * Fetch bulk token scan results from the security alerts API. @@ -1104,10 +1060,10 @@ export class PhishingController extends BaseController< * @param tokens - Array of token addresses to scan. * @returns The API response or null if there was an error. */ - readonly #fetchTokenScanBulkResults = async ( + async #fetchTokenScanBulkResults( chain: string, tokens: string[], - ): Promise => { + ): Promise { const timeout = 8000; // 8 seconds const apiResponse = await safelyExecuteWithTimeout( async () => { @@ -1158,7 +1114,7 @@ export class PhishingController extends BaseController< } return apiResponse as TokenScanApiResponse; - }; + } /** * Scan an address for security alerts. @@ -1167,10 +1123,10 @@ export class PhishingController extends BaseController< * @param address - The address to scan. * @returns The address scan result. */ - scanAddress = async ( + async scanAddress( chainId: string, address: string, - ): Promise => { + ): Promise { if (!address || !chainId) { return { result_type: AddressScanResultType.ErrorResult, @@ -1249,7 +1205,7 @@ export class PhishingController extends BaseController< result_type: apiResponse.result_type, label: apiResponse.label, }; - }; + } /** * Scan multiple tokens for malicious activity in bulk. @@ -1263,9 +1219,9 @@ export class PhishingController extends BaseController< * addresses are lowercased; for non-EVM chains, original casing is preserved. * Tokens that fail to scan are omitted. */ - bulkScanTokens = async ( + async bulkScanTokens( request: BulkTokenScanRequest, - ): Promise => { + ): Promise { const { chainId, tokens } = request; if (!tokens || tokens.length === 0) { @@ -1340,7 +1296,7 @@ export class PhishingController extends BaseController< } return results; - }; + } /** * Process a batch of URLs (up to 50) for phishing detection. @@ -1348,9 +1304,9 @@ export class PhishingController extends BaseController< * @param urls - A batch of URLs to scan. * @returns The scan results and errors for this batch. */ - readonly #processBatch = async ( + async #processBatch( urls: string[], - ): Promise => { + ): Promise { const apiResponse = await safelyExecuteWithTimeout( async () => { const res = await fetch( @@ -1405,15 +1361,15 @@ export class PhishingController extends BaseController< } return apiResponse as BulkPhishingDetectionScanResponse; - }; + } /** * Update the stalelist configuration. * - * This should only be called from the `updateStalelist` function, which is a wrapper around + * This should only be called from the `#updateStalelist` function, which is a wrapper around * this function that prevents redundant configuration updates. */ - async #updateStalelist() { + async #_updateStalelist() { let stalelistResponse: DataResultWrapper | null = null; let hotlistDiffsResponse: DataResultWrapper | null = null; let c2DomainBlocklistResponse: C2DomainBlocklistResponse | null = null; @@ -1474,16 +1430,16 @@ export class PhishingController extends BaseController< this.update((draftState) => { draftState.phishingLists = [newMetaMaskListState]; }); - this.updatePhishingDetector(); + this.#updatePhishingDetector(); } /** * Update the stalelist configuration. * - * This should only be called from the `updateStalelist` function, which is a wrapper around + * This should only be called from the `#updateStalelist` function, which is a wrapper around * this function that prevents redundant configuration updates. */ - async #updateHotlist() { + async #_updateHotlist() { let hotlistResponse: DataResultWrapper | null; try { @@ -1501,6 +1457,7 @@ export class PhishingController extends BaseController< } finally { // Set `hotlistLastFetched` even for failed requests to prevent server from being overwhelmed with // traffic after a network disruption. + console.log('Setting hotlistLastFetched to now:', fetchTimeNow()); this.update((draftState) => { draftState.hotlistLastFetched = fetchTimeNow(); }); @@ -1525,16 +1482,16 @@ export class PhishingController extends BaseController< this.update((draftState) => { draftState.phishingLists = newPhishingLists; }); - this.updatePhishingDetector(); + this.#updatePhishingDetector(); } /** * Update the C2 domain blocklist. * - * This should only be called from the `updateC2DomainBlocklist` function, which is a wrapper around + * This should only be called from the `#updateC2DomainBlocklist` function, which is a wrapper around * this function that prevents redundant configuration updates. */ - async #updateC2DomainBlocklist() { + async #_updateC2DomainBlocklist() { let c2DomainBlocklistResponse: C2DomainBlocklistResponse | null = null; try { @@ -1574,7 +1531,7 @@ export class PhishingController extends BaseController< this.update((draftState) => { draftState.phishingLists = newPhishingLists; }); - this.updatePhishingDetector(); + this.#updatePhishingDetector(); } async #queryConfig( diff --git a/packages/phishing-controller/src/index.ts b/packages/phishing-controller/src/index.ts index 33b89b6e06c..3f2cec2e1ee 100644 --- a/packages/phishing-controller/src/index.ts +++ b/packages/phishing-controller/src/index.ts @@ -1,4 +1,14 @@ export * from './PhishingController'; +export type { + PhishingControllerMaybeUpdateStateAction, + PhishingControllerTestAction, + PhishingControllerIsBlockedRequestAction, + PhishingControllerBypassAction, + PhishingControllerScanUrlAction, + PhishingControllerBulkScanUrlsAction, + PhishingControllerScanAddressAction, + PhishingControllerBulkScanTokensAction, +} from './PhishingController-method-action-types'; export type { LegacyPhishingDetectorList, PhishingDetectorList, diff --git a/packages/phishing-controller/src/tests/utils.ts b/packages/phishing-controller/src/tests/utils.ts index c1b6f3833ff..21fe4b24463 100644 --- a/packages/phishing-controller/src/tests/utils.ts +++ b/packages/phishing-controller/src/tests/utils.ts @@ -5,6 +5,8 @@ import { SimulationTokenStandard, } from '@metamask/transaction-controller'; +import { fetchTimeNow } from '../utils'; + /** * Formats a hostname into a URL so we can parse it correctly * and pass full URLs into the PhishingDetector class. Previously @@ -120,3 +122,14 @@ export const createMockStateChangePayload = ( lastFetchedBlockNumbers: {}, submitHistory: [], }); + +/** + * Determines if a given timestamp is out of date based on a specified interval. + * + * @param lastUpdated - The timestamp of the last update (in milliseconds since epoch). + * @param interval - The interval (in milliseconds) to compare against. + * + * @returns True if the current time is greater than or equal to the last updated time plus the interval, indicating that the data is out of date; otherwise, false. + */ +export const isOutOfDate = (lastUpdated: number, interval: number): boolean => + fetchTimeNow() - lastUpdated >= interval; diff --git a/yarn.lock b/yarn.lock index 1fbef88f858..82417722248 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4743,6 +4743,7 @@ __metadata: nock: "npm:^13.3.1" punycode: "npm:^2.1.1" ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3"