diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 0950c77381..be3888ad20 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1,3 +1,4 @@ +import { ControllerMessenger } from '@metamask/base-controller'; import { strict as assert } from 'assert'; import nock from 'nock'; import * as sinon from 'sinon'; @@ -8,10 +9,45 @@ import { METAMASK_STALELIST_FILE, PhishingController, PHISHING_CONFIG_BASE_URL, + type PhishingControllerActions, + type PhishingControllerOptions, } from './PhishingController'; -const defaultHotlistRefreshInterval = 30 * 60; -const defaultStalelistRefreshInterval = 4 * 24 * 60 * 60; +const controllerName = 'PhishingController'; + +/** + * Constructs a restricted controller messenger. + * + * @returns A restricted controller messenger. + */ +function getRestrictedMessenger() { + const controllerMessenger = new ControllerMessenger< + PhishingControllerActions, + never + >(); + + const messenger = controllerMessenger.getRestricted< + typeof controllerName, + never, + never + >({ + name: 'PhishingController', + }); + + return messenger; +} + +/** + * Contruct a Phishing Controller with the given options if any. + * @param options - The Phishing Controller options. + * @returns The contstructed Phishing Controller. + */ +function getPhishingController(options?: Partial) { + return new PhishingController({ + messenger: getRestrictedMessenger(), + ...options, + }); +} describe('PhishingController', () => { afterEach(() => { @@ -19,23 +55,15 @@ describe('PhishingController', () => { }); it('should have no default phishing lists', () => { - const controller = new PhishingController(); + const controller = getPhishingController(); expect(controller.state.phishingLists).toStrictEqual([]); }); it('should default to an empty whitelist', () => { - const controller = new PhishingController(); + const controller = getPhishingController(); expect(controller.state.whitelist).toStrictEqual([]); }); - it('should use default stalelist & hotlist refresh intervals', () => { - const controller = new PhishingController(); - expect(controller.config).toStrictEqual({ - stalelistRefreshInterval: defaultStalelistRefreshInterval, - hotlistRefreshInterval: defaultHotlistRefreshInterval, - }); - }); - it('does not call update stalelist or hotlist upon construction', async () => { const nockScope = nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) @@ -56,7 +84,7 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - new PhishingController({}); + getPhishingController(); expect(nockScope.isDone()).toBe(false); }); @@ -82,7 +110,7 @@ describe('PhishingController', () => { ], }); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, }); clock.tick(1000 * 10); @@ -137,7 +165,7 @@ describe('PhishingController', () => { it('should not have stalelist be out of date immediately after maybeUpdateState is called', async () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); clock.tick(1000 * 10); @@ -149,7 +177,7 @@ describe('PhishingController', () => { it('should not be out of date after maybeUpdateStalelist is called but before refresh interval has passed', async () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); clock.tick(1000 * 10); @@ -162,7 +190,7 @@ describe('PhishingController', () => { it('should still be out of date while update is in progress', async () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); clock.tick(1000 * 10); @@ -178,7 +206,7 @@ describe('PhishingController', () => { it('should call update only if it is out of date, otherwise it should not call update', async () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); expect(controller.isStalelistOutOfDate()).toBe(false); @@ -236,7 +264,7 @@ describe('PhishingController', () => { ], }); const clock = sinon.useFakeTimers(50); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, stalelistRefreshInterval: 50, }); @@ -250,7 +278,7 @@ describe('PhishingController', () => { describe('isStalelistOutOfDate', () => { it('should not be out of date upon construction', () => { sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); @@ -259,7 +287,7 @@ describe('PhishingController', () => { it('should not be out of date after some of the refresh interval has passed', () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); clock.tick(1000 * 5); @@ -269,7 +297,7 @@ describe('PhishingController', () => { it('should be out of date after the refresh interval has passed', () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); clock.tick(1000 * 10); @@ -279,7 +307,7 @@ describe('PhishingController', () => { it('should be out of date if the refresh interval has passed and an update is in progress', async () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); clock.tick(1000 * 10); @@ -293,7 +321,7 @@ describe('PhishingController', () => { it('should not be out of date if the phishing lists were just updated', async () => { sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); await controller.updateStalelist(); @@ -303,18 +331,18 @@ describe('PhishingController', () => { it('should not be out of date if the phishing lists were recently updated', async () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); await controller.updateStalelist(); - await clock.tick(1000 * 5); + clock.tick(1000 * 5); expect(controller.isStalelistOutOfDate()).toBe(false); }); it('should be out of date if the time elapsed since the last update equals the refresh interval', async () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ stalelistRefreshInterval: 10, }); await controller.updateStalelist(); @@ -327,7 +355,7 @@ describe('PhishingController', () => { describe('isHotlistOutOfDate', () => { it('should not be out of date upon construction', () => { sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, }); @@ -336,7 +364,7 @@ describe('PhishingController', () => { it('should not be out of date after some of the refresh interval has passed', () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, }); clock.tick(1000 * 5); @@ -346,7 +374,7 @@ describe('PhishingController', () => { it('should be out of date after the refresh interval has passed', () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, }); clock.tick(1000 * 10); @@ -356,7 +384,7 @@ describe('PhishingController', () => { it('should be out of date if the refresh interval has passed and an update is in progress', async () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, }); clock.tick(1000 * 10); @@ -370,7 +398,7 @@ describe('PhishingController', () => { it('should not be out of date if the phishing lists were just updated', async () => { sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, }); await controller.updateHotlist(); @@ -380,18 +408,18 @@ describe('PhishingController', () => { it('should not be out of date if the phishing lists were recently updated', async () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, }); await controller.updateHotlist(); - await clock.tick(1000 * 5); + clock.tick(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 () => { const clock = sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, }); await controller.updateHotlist(); @@ -403,7 +431,7 @@ describe('PhishingController', () => { it('should be able to change the stalelistRefreshInterval', async () => { sinon.useFakeTimers(); - const controller = new PhishingController({ stalelistRefreshInterval: 10 }); + const controller = getPhishingController({ stalelistRefreshInterval: 10 }); controller.setStalelistRefreshInterval(0); expect(controller.isStalelistOutOfDate()).toBe(true); @@ -411,7 +439,7 @@ describe('PhishingController', () => { it('should be able to change the hotlistRefreshInterval', async () => { sinon.useFakeTimers(); - const controller = new PhishingController({ + const controller = getPhishingController({ hotlistRefreshInterval: 10, }); controller.setHotlistRefreshInterval(0); @@ -439,7 +467,7 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.test('metamask.io')).toMatchObject({ result: false, @@ -469,7 +497,7 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.test('i❤.ws')).toMatchObject({ result: false, @@ -498,7 +526,7 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.test('xn--i-7iq.ws')).toMatchObject({ result: false, @@ -527,7 +555,7 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.test('etnerscan.io')).toMatchObject({ result: true, @@ -556,7 +584,7 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.test('myetherẉalletṭ.com')).toMatchObject({ result: true, @@ -586,7 +614,7 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.test('xn--myetherallet-4k5fwn.com')).toMatchObject({ result: true, @@ -624,7 +652,7 @@ describe('PhishingController', () => { ], }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect( controller.test('e4d600ab9141b7a9859511c77e63b9b3.com'), @@ -656,7 +684,7 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(500); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect( controller.test('e4d600ab9141b7a9859511c77e63b9b3.com'), @@ -686,7 +714,7 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.test('opensea.io')).toMatchObject({ result: false, @@ -715,7 +743,7 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.test('ohpensea.io')).toMatchObject({ result: true, @@ -744,7 +772,7 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect( controller.test('this-is-the-official-website-of-opensea.io'), @@ -774,7 +802,7 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); const unsafeDomain = 'electrum.mx'; assert.equal( @@ -809,7 +837,7 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); const unsafeDomain = 'electrum.mx'; assert.equal( @@ -845,7 +873,7 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); const unsafeDomain = 'myetherẉalletṭ.com'; assert.equal( @@ -880,7 +908,7 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); const unsafeDomain = 'xn--myetherallet-4k5fwn.com'; assert.equal( @@ -930,7 +958,7 @@ describe('PhishingController', () => { ], }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.state.phishingLists).toStrictEqual([ @@ -993,7 +1021,7 @@ describe('PhishingController', () => { ], }); - const controller = new PhishingController(); + const controller = getPhishingController(); await controller.updateStalelist(); expect(controller.state.phishingLists).toStrictEqual([ @@ -1018,10 +1046,15 @@ describe('PhishingController', () => { ]); }); - it('should not update stale list if disabled', async () => { - const controller = new PhishingController( - { disabled: true }, - { + 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({ + state: { phishingLists: [ { allowlist: [], @@ -1034,7 +1067,7 @@ describe('PhishingController', () => { }, ], }, - ); + }); await controller.updateStalelist(); expect(controller.state.phishingLists).toStrictEqual([ @@ -1050,10 +1083,15 @@ describe('PhishingController', () => { ]); }); - it('should not update hotlist lists if disabled', async () => { - const controller = new PhishingController( - { disabled: true }, - { + 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); + + const controller = getPhishingController({ + state: { phishingLists: [ { allowlist: [], @@ -1066,76 +1104,6 @@ describe('PhishingController', () => { }, ], }, - ); - await controller.updateHotlist(); - - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], - blocklist: [], - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 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 = new PhishingController(undefined, { - phishingLists: [ - { - allowlist: [], - blocklist: [], - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], - }); - await controller.updateStalelist(); - - expect(controller.state.phishingLists).toStrictEqual([ - { - allowlist: [], - blocklist: [], - 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); - - const controller = new PhishingController(undefined, { - phishingLists: [ - { - allowlist: [], - blocklist: [], - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], }); await controller.updateStalelist(); @@ -1159,7 +1127,7 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .replyWithError('network error'); - const controller = new PhishingController(); + const controller = getPhishingController(); expect(await controller.updateStalelist()).toBeUndefined(); }); @@ -1189,7 +1157,7 @@ describe('PhishingController', () => { .delay(100) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); const firstPromise = controller.updateStalelist(); const secondPromise = controller.updateStalelist(); @@ -1227,7 +1195,7 @@ describe('PhishingController', () => { .delay(100) .reply(200, { data: [] }); - const controller = new PhishingController(); + const controller = getPhishingController(); const firstPromise = controller.updateStalelist(); const secondPromise = controller.updateStalelist(); clock.tick(1000 * 99); @@ -1255,18 +1223,20 @@ describe('PhishingController', () => { ], }); - const controller = new PhishingController(undefined, { - phishingLists: [ - { - allowlist: [], - blocklist: [], - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + }, }); await controller.updateHotlist(); @@ -1287,18 +1257,20 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${0}`) .reply(404); - const controller = new PhishingController(undefined, { - phishingLists: [ - { - allowlist: [], - blocklist: [], - fuzzylist: [], - tolerance: 3, - version: 1, - name: ListNames.MetaMask, - lastUpdated: 0, - }, - ], + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + }, }); await controller.updateHotlist(); diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 80ae1cd16c..2c95e86587 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -1,11 +1,24 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; -import { BaseController } from '@metamask/base-controller'; +import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import { BaseControllerV2 as BaseController } from '@metamask/base-controller'; import { safelyExecute } from '@metamask/controller-utils'; import PhishingDetector from 'eth-phishing-detect/src/detector'; import { toASCII } from 'punycode/'; import { applyDiffs, fetchTimeNow } from './utils'; +export const PHISHING_CONFIG_BASE_URL = + 'https://phishing-detection.metafi.codefi.network'; + +export const METAMASK_STALELIST_FILE = '/v1/stalelist'; + +export const METAMASK_HOTLIST_DIFF_FILE = '/v1/diffsSince'; + +export const HOTLIST_REFRESH_INTERVAL = 30 * 60; // 30 mins in seconds +export const STALELIST_REFRESH_INTERVAL = 4 * 24 * 60 * 60; // 4 days in seconds + +export const METAMASK_STALELIST_URL = `${PHISHING_CONFIG_BASE_URL}${METAMASK_STALELIST_FILE}`; +export const METAMASK_HOTLIST_DIFF_URL = `${PHISHING_CONFIG_BASE_URL}${METAMASK_HOTLIST_DIFF_FILE}`; + /** * @type ListTypes * @@ -24,36 +37,36 @@ export type ListTypes = 'fuzzylist' | 'blocklist' | 'allowlist'; * @property version - Version number of this configuration * @property whitelist - List of approved origins */ -export interface EthPhishingResponse { +export type EthPhishingResponse = { blacklist: string[]; fuzzylist: string[]; tolerance: number; version: number; whitelist: string[]; -} +}; /** * @type PhishingStalelist * - * Interface defining expected type of the stalelist.json file. + * type defining expected type of the stalelist.json file. * @property eth_phishing_detect_config - Stale list sourced from eth-phishing-detect's config.json. * @property phishfort_hotlist - Stale list sourced from phishfort's hotlist.json. Only includes blocklist. Deduplicated entries from eth_phishing_detect_config. * @property tolerance - Fuzzy match tolerance level * @property lastUpdated - Timestamp of last update. * @property version - Stalelist data structure iteration. */ -export interface PhishingStalelist { +export type PhishingStalelist = { eth_phishing_detect_config: Record; phishfort_hotlist: Record; tolerance: number; version: number; lastUpdated: number; -} +}; /** * @type PhishingListState * - * Interface defining the persisted list state. This is the persisted state that is updated frequently with `this.maybeUpdateState()`. + * type defining the persisted list state. This is the persisted state that is updated frequently with `this.maybeUpdateState()`. * @property allowlist - List of approved origins (legacy naming "whitelist") * @property blocklist - List of unapproved origins (legacy naming "blacklist") * @property fuzzylist - List of fuzzy-matched unapproved origins @@ -62,7 +75,7 @@ export interface PhishingStalelist { * @property version - Version of the phishing list state. * @property name - Name of the list. Used for attribution. */ -export interface PhishingListState { +export type PhishingListState = { allowlist: string[]; blocklist: string[]; fuzzylist: string[]; @@ -70,45 +83,45 @@ export interface PhishingListState { version: number; lastUpdated: number; name: ListNames; -} +}; /** * @type EthPhishingDetectResult * - * Interface that describes the result of the `test` method. + * type that describes the result of the `test` method. * @property name - Name of the config on which a match was found. * @property version - Version of the config on which a match was found. * @property result - Whether a domain was detected as a phishing domain. True means an unsafe domain. * @property match - The matching fuzzylist origin when a fuzzylist match is found. Returned as undefined for non-fuzzy true results. * @property type - The field of the config on which a match was found. */ -export interface EthPhishingDetectResult { +export type EthPhishingDetectResult = { name?: string; version?: string; result: boolean; match?: string; // Returned as undefined for non-fuzzy true results. type: 'all' | 'fuzzy' | 'blocklist' | 'allowlist'; -} +}; /** * @type HotlistDiff * - * Interface defining the expected type of the diffs in hotlist.json file. + * type defining the expected type of the diffs in hotlist.json file. * @property url - Url of the diff entry. * @property timestamp - Timestamp at which the diff was identified. * @property targetList - The list name where the diff was identified. * @property isRemoval - Was the diff identified a removal type. */ -export interface HotlistDiff { +export type HotlistDiff = { url: string; timestamp: number; targetList: `${ListKeys}.${ListTypes}`; isRemoval?: boolean; -} +}; -export interface DataResultWrapper { +export type DataResultWrapper = { data: T; -} +}; /** * @type Hotlist @@ -121,45 +134,6 @@ export interface DataResultWrapper { */ export type Hotlist = HotlistDiff[]; -/** - * @type PhishingConfig - * - * Phishing controller configuration - * @property stalelistRefreshInterval - Polling interval used to fetch stale list. - * @property hotlistRefreshInterval - Polling interval used to fetch hotlist diff list. - */ -export interface PhishingConfig extends BaseConfig { - stalelistRefreshInterval: number; - hotlistRefreshInterval: number; -} - -/** - * @type PhishingState - * - * Phishing controller state - * @property phishing - eth-phishing-detect configuration - * @property whitelist - array of temporarily-approved origins - */ -export interface PhishingState extends BaseState { - phishingLists: PhishingListState[]; - whitelist: string[]; - hotlistLastFetched: number; - stalelistLastFetched: number; -} - -export const PHISHING_CONFIG_BASE_URL = - 'https://phishing-detection.metafi.codefi.network'; - -export const METAMASK_STALELIST_FILE = '/v1/stalelist'; - -export const METAMASK_HOTLIST_DIFF_FILE = '/v1/diffsSince'; - -export const HOTLIST_REFRESH_INTERVAL = 30 * 60; // 30 mins in seconds -export const STALELIST_REFRESH_INTERVAL = 4 * 24 * 60 * 60; // 4 days in seconds - -export const METAMASK_STALELIST_URL = `${PHISHING_CONFIG_BASE_URL}${METAMASK_STALELIST_FILE}`; -export const METAMASK_HOTLIST_DIFF_URL = `${PHISHING_CONFIG_BASE_URL}${METAMASK_HOTLIST_DIFF_FILE}`; - /** * Enum containing upstream data provider source list keys. * These are the keys denoting lists consumed by the upstream data provider. @@ -195,56 +169,147 @@ export const phishingListKeyNameMap = { [ListKeys.PhishfortHotlist]: ListNames.Phishfort, }; +const controllerName = 'PhishingController'; + +const metadata = { + phishingLists: { persist: true, anonymous: false }, + whitelist: { persist: true, anonymous: false }, + hotlistLastFetched: { persist: true, anonymous: false }, + stalelistLastFetched: { persist: true, anonymous: false }, +}; + +/** + * Get a default empty state for the controller. + * @returns The default empty state. + */ +const getDefaultState = (): PhishingControllerState => { + return { + phishingLists: [], + whitelist: [], + hotlistLastFetched: 0, + stalelistLastFetched: 0, + }; +}; + +/** + * @type PhishingControllerState + * + * Phishing controller state + * @property phishing - eth-phishing-detect configuration + * @property whitelist - array of temporarily-approved origins + */ +export type PhishingControllerState = { + phishingLists: PhishingListState[]; + whitelist: string[]; + hotlistLastFetched: number; + stalelistLastFetched: number; +}; + +/** + * @type PhishingControllerOptions + * + * Phishing controller options + * @property stalelistRefreshInterval - Polling interval used to fetch stale list. + * @property hotlistRefreshInterval - Polling interval used to fetch hotlist diff list. + */ +export type PhishingControllerOptions = { + stalelistRefreshInterval?: number; + hotlistRefreshInterval?: number; + messenger: PhishingControllerMessenger; + state?: Partial; +}; + +export type MaybeUpdateState = { + type: `${typeof controllerName}:maybeUpdateState`; + handler: PhishingController['maybeUpdateState']; +}; + +export type TestOrigin = { + type: `${typeof controllerName}:testOrigin`; + handler: PhishingController['test']; +}; + +export type PhishingControllerActions = MaybeUpdateState | TestOrigin; + +export type PhishingControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + PhishingControllerActions, + never, + never, + never +>; + /** * Controller that manages community-maintained lists of approved and unapproved website origins. */ export class PhishingController extends BaseController< - PhishingConfig, - PhishingState + typeof controllerName, + PhishingControllerState, + PhishingControllerMessenger > { - private detector: any; + #detector: any; - #inProgressHotlistUpdate: Promise | undefined; + #stalelistRefreshInterval: number; - #inProgressStalelistUpdate: Promise | undefined; + #hotlistRefreshInterval: number; - /** - * Name of this controller used during composition - */ - override name = 'PhishingController'; + #inProgressHotlistUpdate?: Promise; + + #inProgressStalelistUpdate?: Promise; /** - * Creates a PhishingController instance. + * Construct a Phishing Controller. * * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. + * @param config.stalelistRefreshInterval - Polling interval used to fetch stale list. + * @param config.hotlistRefreshInterval - Polling interval used to fetch hotlist diff list. + * @param config.messenger - The controller restricted messenger. + * @param config.state - Initial state to set on this controller. */ - constructor( - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - stalelistRefreshInterval: STALELIST_REFRESH_INTERVAL, - hotlistRefreshInterval: HOTLIST_REFRESH_INTERVAL, - }; + constructor({ + stalelistRefreshInterval = STALELIST_REFRESH_INTERVAL, + hotlistRefreshInterval = HOTLIST_REFRESH_INTERVAL, + messenger, + state = {}, + }: PhishingControllerOptions) { + super({ + name: controllerName, + metadata, + messenger, + state: { + ...getDefaultState(), + ...state, + }, + }); - this.defaultState = { - phishingLists: [], - whitelist: [], - hotlistLastFetched: 0, - stalelistLastFetched: 0, - }; + this.#stalelistRefreshInterval = stalelistRefreshInterval; + this.#hotlistRefreshInterval = hotlistRefreshInterval; + this.#registerMessageHandlers(); - this.initialize(); this.updatePhishingDetector(); } + /** + * Constructor helper for registering this controller's messaging system + * actions. + */ + #registerMessageHandlers(): void { + this.messagingSystem.registerActionHandler( + `${controllerName}:maybeUpdateState` as const, + this.maybeUpdateState.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:testOrigin` as const, + this.test.bind(this), + ); + } + /** * Updates this.detector with an instance of PhishingDetector using the current state. */ updatePhishingDetector() { - this.detector = new PhishingDetector(this.state.phishingLists); + this.#detector = new PhishingDetector(this.state.phishingLists); } /** @@ -255,7 +320,7 @@ export class PhishingController extends BaseController< * @param interval - the new interval, in ms. */ setStalelistRefreshInterval(interval: number) { - this.configure({ stalelistRefreshInterval: interval }, false, false); + this.#stalelistRefreshInterval = interval; } /** @@ -266,7 +331,7 @@ export class PhishingController extends BaseController< * @param interval - the new interval, in ms. */ setHotlistRefreshInterval(interval: number) { - this.configure({ hotlistRefreshInterval: interval }, false, false); + this.#hotlistRefreshInterval = interval; } /** @@ -277,7 +342,7 @@ export class PhishingController extends BaseController< isStalelistOutOfDate() { return ( fetchTimeNow() - this.state.stalelistLastFetched >= - this.config.stalelistRefreshInterval + this.#stalelistRefreshInterval ); } @@ -289,7 +354,7 @@ export class PhishingController extends BaseController< isHotlistOutOfDate() { return ( fetchTimeNow() - this.state.hotlistLastFetched >= - this.config.hotlistRefreshInterval + this.#hotlistRefreshInterval ); } @@ -328,7 +393,7 @@ export class PhishingController extends BaseController< if (this.state.whitelist.includes(punycodeOrigin)) { return { result: false, type: 'all' }; // Same as whitelisted match returned by detector.check(...). } - return this.detector.check(punycodeOrigin); + return this.#detector.check(punycodeOrigin); } /** @@ -342,7 +407,9 @@ export class PhishingController extends BaseController< if (whitelist.includes(punycodeOrigin)) { return; } - this.update({ whitelist: [...whitelist, punycodeOrigin] }); + this.update((draftState) => { + draftState.whitelist.push(punycodeOrigin); + }); } /** @@ -392,21 +459,17 @@ export class PhishingController extends BaseController< * this function that prevents redundant configuration updates. */ async #updateStalelist() { - if (this.disabled) { - return; - } - let stalelistResponse; let hotlistDiffsResponse; try { - stalelistResponse = await this.queryConfig< + stalelistResponse = await this.#queryConfig< DataResultWrapper >(METAMASK_STALELIST_URL).then((d) => d); // Fetching hotlist diffs relies on having a lastUpdated timestamp to do `GET /v1/diffsSince/:timestamp`, // so it doesn't make sense to call if there is not a timestamp to begin with. if (stalelistResponse?.data && stalelistResponse.data.lastUpdated > 0) { - hotlistDiffsResponse = await this.queryConfig< + hotlistDiffsResponse = await this.#queryConfig< DataResultWrapper >(`${METAMASK_HOTLIST_DIFF_URL}/${stalelistResponse.data.lastUpdated}`); } @@ -414,9 +477,9 @@ export class PhishingController extends BaseController< // Set `stalelistLastFetched` and `hotlistLastFetched` even for failed requests to prevent server // from being overwhelmed with traffic after a network disruption. const timeNow = fetchTimeNow(); - this.update({ - stalelistLastFetched: timeNow, - hotlistLastFetched: timeNow, + this.update((draftState) => { + draftState.stalelistLastFetched = timeNow; + draftState.hotlistLastFetched = timeNow; }); } @@ -451,8 +514,8 @@ export class PhishingController extends BaseController< ListKeys.EthPhishingDetectConfig, ); - this.update({ - phishingLists: [newMetaMaskListState, newPhishfortListState], + this.update((draftState) => { + draftState.phishingLists = [newMetaMaskListState, newPhishfortListState]; }); this.updatePhishingDetector(); } @@ -464,23 +527,20 @@ export class PhishingController extends BaseController< * this function that prevents redundant configuration updates. */ async #updateHotlist() { - if (this.disabled) { - return; - } const lastDiffTimestamp = Math.max( ...this.state.phishingLists.map(({ lastUpdated }) => lastUpdated), ); let hotlistResponse: DataResultWrapper | null; try { - hotlistResponse = await this.queryConfig>( + hotlistResponse = await this.#queryConfig>( `${METAMASK_HOTLIST_DIFF_URL}/${lastDiffTimestamp}`, ); } finally { // Set `hotlistLastFetched` even for failed requests to prevent server from being overwhelmed with // traffic after a network disruption. - this.update({ - hotlistLastFetched: fetchTimeNow(), + this.update((draftState) => { + draftState.hotlistLastFetched = fetchTimeNow(); }); } @@ -496,13 +556,13 @@ export class PhishingController extends BaseController< ), ); - this.update({ - phishingLists: newPhishingLists, + this.update((draftState) => { + draftState.phishingLists = newPhishingLists; }); this.updatePhishingDetector(); } - private async queryConfig( + async #queryConfig( input: RequestInfo, ): Promise { const response = await safelyExecute(