From 2f1404ac34c12d36ca295b4bf738531fa3c9ab3a Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 29 Jan 2026 00:41:14 +0100 Subject: [PATCH 01/10] feat(metrics): add eth-chainlist domain check to isPublicEndpointUrl --- app/scripts/lib/util.ts | 62 ++------- shared/lib/network-utils.test.ts | 226 ++++++++++++++++++++++++++++++- shared/lib/network-utils.ts | 97 ++++++++++++- 3 files changed, 333 insertions(+), 52 deletions(-) diff --git a/app/scripts/lib/util.ts b/app/scripts/lib/util.ts index 784c6fdbace8..d8708a7983dd 100644 --- a/app/scripts/lib/util.ts +++ b/app/scripts/lib/util.ts @@ -25,7 +25,10 @@ import { import { CHAIN_IDS, TEST_CHAINS } from '../../../shared/constants/network'; import { stripHexPrefix } from '../../../shared/modules/hexstring-utils'; import { getMethodDataAsync } from '../../../shared/lib/four-byte'; -import { getSafeChainsListFromCacheOnly } from '../../../shared/lib/network-utils'; +import { + initializeChainlistDomains, + isChainlistDomain, +} from '../../../shared/lib/network-utils'; /** * @see {@link getEnvironmentType} @@ -474,53 +477,12 @@ export function getConversionRatesForNativeAsset({ return conversionRateResult; } -// Cache for known domains -let knownDomainsSet: Set | null = null; -let initPromise: Promise | null = null; - -/** - * Initialize the set of known domains from the chains list - */ -export async function initializeRpcProviderDomains(): Promise { - if (initPromise) { - return initPromise; - } - - initPromise = (async () => { - try { - const chainsList = await getSafeChainsListFromCacheOnly(); - knownDomainsSet = new Set(); - - for (const chain of chainsList) { - if (chain.rpc && Array.isArray(chain.rpc)) { - for (const rpcUrl of chain.rpc) { - try { - const url = new URL(rpcUrl); - knownDomainsSet.add(url.hostname); - } catch (e) { - // Skip invalid URLs - continue; - } - } - } - } - } catch (error) { - console.error('Error initializing known domains:', error); - knownDomainsSet = new Set(); - } - })(); - - return initPromise; -} - -/** - * Check if a domain is in the known domains list - * - * @param domain - The domain to check - */ -export function isKnownDomain(domain: string): boolean { - return knownDomainsSet?.has(domain?.toLowerCase()) ?? false; -} +// Re-export chainlist domain functions for backward compatibility +// These were moved to shared/lib/network-utils.ts +export { + initializeChainlistDomains as initializeRpcProviderDomains, + isChainlistDomain as isKnownDomain, +}; /** * Extracts the domain from an RPC endpoint URL with privacy considerations @@ -556,12 +518,12 @@ export function extractRpcDomain( } } - // Use the provided test domains if available, otherwise use isKnownDomain + // Use the provided test domains if available, otherwise use isChainlistDomain if (knownDomainsForTesting) { if (knownDomainsForTesting.has(url.hostname.toLowerCase())) { return url.hostname.toLowerCase(); } - } else if (isKnownDomain(url.hostname)) { + } else if (isChainlistDomain(url.hostname)) { return url.hostname.toLowerCase(); } diff --git a/shared/lib/network-utils.test.ts b/shared/lib/network-utils.test.ts index cafdb57a3e5d..26e656ed784f 100644 --- a/shared/lib/network-utils.test.ts +++ b/shared/lib/network-utils.test.ts @@ -8,7 +8,12 @@ import { getIsMetaMaskInfuraEndpointUrl, getIsQuicknodeEndpointUrl, isPublicEndpointUrl, + initializeChainlistDomains, + isChainlistDomain, + isChainlistEndpointUrl, + resetChainlistDomainsCache, } from './network-utils'; +import * as storageHelpers from './storage-helpers'; jest.mock('../constants/network', () => ({ FEATURED_RPCS: [ @@ -193,7 +198,8 @@ describe('isPublicEndpointUrl', () => { ).toBe(true); }); - it('returns false for unknown URLs', () => { + it('returns false for unknown URLs when chainlist is not initialized', () => { + resetChainlistDomainsCache(); expect( isPublicEndpointUrl( 'https://unknown.example.com', @@ -201,4 +207,222 @@ describe('isPublicEndpointUrl', () => { ), ).toBe(false); }); + + it('returns true for chainlist domains after initialization', async () => { + resetChainlistDomainsCache(); + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [ + { + name: 'Test Chain', + chainId: 1, + rpc: ['https://chainlist-rpc.example.com/rpc'], + }, + ], + }); + + await initializeChainlistDomains(); + + expect( + isPublicEndpointUrl( + 'https://chainlist-rpc.example.com/v1/abc123', + MOCK_METAMASK_INFURA_PROJECT_ID, + ), + ).toBe(true); + }); +}); + +describe('initializeChainlistDomains', () => { + beforeEach(() => { + resetChainlistDomainsCache(); + jest.clearAllMocks(); + }); + + it('initializes domains from cached chains list', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [ + { + name: 'Ethereum Mainnet', + chainId: 1, + rpc: [ + 'https://mainnet.infura.io/v3/key', + 'https://eth-mainnet.alchemyapi.io/v2/key', + ], + }, + { + name: 'Polygon', + chainId: 137, + rpc: ['https://polygon-rpc.com'], + }, + ], + }); + + await initializeChainlistDomains(); + + expect(isChainlistDomain('mainnet.infura.io')).toBe(true); + expect(isChainlistDomain('eth-mainnet.alchemyapi.io')).toBe(true); + expect(isChainlistDomain('polygon-rpc.com')).toBe(true); + expect(isChainlistDomain('unknown.com')).toBe(false); + }); + + it('handles empty chains list', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [], + }); + + await initializeChainlistDomains(); + + expect(isChainlistDomain('any-domain.com')).toBe(false); + }); + + it('handles missing cache', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue(undefined); + + await initializeChainlistDomains(); + + expect(isChainlistDomain('any-domain.com')).toBe(false); + }); + + it('handles chains without rpc field', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [ + { + name: 'Chain Without RPC', + chainId: 999, + }, + ], + }); + + await initializeChainlistDomains(); + + expect(isChainlistDomain('any-domain.com')).toBe(false); + }); + + it('skips invalid URLs in rpc list', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [ + { + name: 'Chain With Invalid RPC', + chainId: 999, + rpc: ['not-a-valid-url', 'https://valid-rpc.com'], + }, + ], + }); + + await initializeChainlistDomains(); + + expect(isChainlistDomain('valid-rpc.com')).toBe(true); + }); + + it('only fetches from storage once on subsequent calls', async () => { + const getStorageItemSpy = jest + .spyOn(storageHelpers, 'getStorageItem') + .mockResolvedValue({ + cachedResponse: [], + }); + + await initializeChainlistDomains(); + await initializeChainlistDomains(); + await initializeChainlistDomains(); + + // Storage should only be accessed once despite multiple calls + expect(getStorageItemSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('isChainlistDomain', () => { + beforeEach(() => { + resetChainlistDomainsCache(); + }); + + it('returns false when not initialized', () => { + expect(isChainlistDomain('any-domain.com')).toBe(false); + }); + + it('returns false for empty domain', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [{ name: 'Test', chainId: 1, rpc: ['https://test.com'] }], + }); + await initializeChainlistDomains(); + + expect(isChainlistDomain('')).toBe(false); + }); + + it('is case insensitive', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [ + { name: 'Test', chainId: 1, rpc: ['https://Test-RPC.COM/path'] }, + ], + }); + await initializeChainlistDomains(); + + expect(isChainlistDomain('test-rpc.com')).toBe(true); + expect(isChainlistDomain('TEST-RPC.COM')).toBe(true); + expect(isChainlistDomain('Test-RPC.com')).toBe(true); + }); +}); + +describe('isChainlistEndpointUrl', () => { + beforeEach(() => { + resetChainlistDomainsCache(); + }); + + it('returns false when not initialized', () => { + expect(isChainlistEndpointUrl('https://any-domain.com/rpc')).toBe(false); + }); + + it('returns true for URLs with chainlist domains', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [ + { name: 'Test', chainId: 1, rpc: ['https://chainlist-domain.com'] }, + ], + }); + await initializeChainlistDomains(); + + expect( + isChainlistEndpointUrl('https://chainlist-domain.com/v1/abc123'), + ).toBe(true); + expect(isChainlistEndpointUrl('https://chainlist-domain.com')).toBe(true); + }); + + it('returns false for URLs with unknown domains', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [ + { name: 'Test', chainId: 1, rpc: ['https://known-domain.com'] }, + ], + }); + await initializeChainlistDomains(); + + expect(isChainlistEndpointUrl('https://unknown-domain.com/rpc')).toBe( + false, + ); + }); + + it('returns false for invalid URLs', () => { + expect(isChainlistEndpointUrl('not-a-url')).toBe(false); + expect(isChainlistEndpointUrl('')).toBe(false); + }); +}); + +describe('resetChainlistDomainsCache', () => { + it('clears the cache and allows reinitialization', async () => { + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [ + { name: 'First', chainId: 1, rpc: ['https://first-domain.com'] }, + ], + }); + await initializeChainlistDomains(); + expect(isChainlistDomain('first-domain.com')).toBe(true); + + resetChainlistDomainsCache(); + expect(isChainlistDomain('first-domain.com')).toBe(false); + + jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ + cachedResponse: [ + { name: 'Second', chainId: 2, rpc: ['https://second-domain.com'] }, + ], + }); + await initializeChainlistDomains(); + expect(isChainlistDomain('second-domain.com')).toBe(true); + expect(isChainlistDomain('first-domain.com')).toBe(false); + }); }); diff --git a/shared/lib/network-utils.ts b/shared/lib/network-utils.ts index ac88803fdf83..fa572b33b71d 100644 --- a/shared/lib/network-utils.ts +++ b/shared/lib/network-utils.ts @@ -9,6 +9,10 @@ import { getStorageItem } from './storage-helpers'; const cacheKey = `cachedFetch:${CHAIN_SPEC_URL}`; +// Cache for known domains from eth-chainlist +let knownDomainsSet: Set | null = null; +let initPromise: Promise | null = null; + type ChainInfo = { name: string; shortName?: string; @@ -107,10 +111,101 @@ export function isPublicEndpointUrl( const isQuicknodeEndpointUrl = getIsQuicknodeEndpointUrl(endpointUrl); const isKnownCustomEndpointUrl = KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl); + const isChainlistEndpoint = isChainlistEndpointUrl(endpointUrl); return ( isMetaMaskInfuraEndpointUrl || isQuicknodeEndpointUrl || - isKnownCustomEndpointUrl + isKnownCustomEndpointUrl || + isChainlistEndpoint ); } + +/** + * Extracts the hostname from a URL. + * + * @param url - The URL to extract the hostname from. + * @returns The lowercase hostname, or null if the URL is invalid. + */ +function extractHostname(url: string): string | null { + try { + const parsedUrl = new URL(url); + return parsedUrl.hostname.toLowerCase(); + } catch { + return null; + } +} + +/** + * Initialize the set of known domains from the eth-chainlist cache. + * This should be called once at startup in the background context only. + * The UI should NOT call this to avoid the ~300KB memory footprint. + * When not initialized, isChainlistDomain() returns false, which means + * isPublicEndpointUrl() falls back to checking Infura, Quicknode, and + * known custom endpoints only. + * + * @returns A promise that resolves when initialization is complete. + */ +export async function initializeChainlistDomains(): Promise { + if (initPromise) { + return initPromise; + } + + initPromise = (async () => { + try { + const chainsList = await getSafeChainsListFromCacheOnly(); + knownDomainsSet = new Set(); + + for (const chain of chainsList) { + if (chain.rpc && Array.isArray(chain.rpc)) { + for (const rpcUrl of chain.rpc) { + const hostname = extractHostname(rpcUrl); + if (hostname) { + knownDomainsSet.add(hostname); + } + } + } + } + } catch (error) { + console.error('Error initializing chainlist domains:', error); + knownDomainsSet = new Set(); + } + })(); + + return initPromise; +} + +/** + * Check if a domain is in the eth-chainlist (cached). + * + * @param domain - The domain to check. + * @returns True if the domain is found in the chainlist cache. + */ +export function isChainlistDomain(domain: string): boolean { + if (!domain) { + return false; + } + return knownDomainsSet?.has(domain.toLowerCase()) ?? false; +} + +/** + * Check if an RPC endpoint URL's domain is defined in eth-chainlist. + * + * @param endpointUrl - The URL of the RPC endpoint. + * @returns True if the endpoint's domain is in the chainlist. + */ +export function isChainlistEndpointUrl(endpointUrl: string): boolean { + const hostname = extractHostname(endpointUrl); + if (!hostname) { + return false; + } + return isChainlistDomain(hostname); +} + +/** + * Resets the chainlist domains cache. Useful for testing. + */ +export function resetChainlistDomainsCache(): void { + knownDomainsSet = null; + initPromise = null; +} From f8c585bde66a37062b035b38883cb2b6b884cfcc Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 29 Jan 2026 19:59:02 +0100 Subject: [PATCH 02/10] refactor(metrics): move isPublicEndpointUrl to background API --- .../messenger-action-handlers.test.ts | 20 +- .../messenger-action-handlers.ts | 2 +- app/scripts/lib/util.test.js | 23 ++ app/scripts/lib/util.ts | 130 +++++++- app/scripts/metamask-controller.js | 3 + shared/lib/network-utils.test.ts | 278 +----------------- shared/lib/network-utils.ts | 123 +------- ui/hooks/useNetworkConnectionBanner.test.ts | 19 +- ui/hooks/useNetworkConnectionBanner.ts | 20 +- .../networks-form/networks-form.tsx | 26 +- 10 files changed, 196 insertions(+), 448 deletions(-) diff --git a/app/scripts/lib/network-controller/messenger-action-handlers.test.ts b/app/scripts/lib/network-controller/messenger-action-handlers.test.ts index 879337e870fb..6844458f4154 100644 --- a/app/scripts/lib/network-controller/messenger-action-handlers.test.ts +++ b/app/scripts/lib/network-controller/messenger-action-handlers.test.ts @@ -1,5 +1,5 @@ import { HttpError } from '@metamask/controller-utils'; -import * as networkUtilsModule from '../../../../shared/lib/network-utils'; +import * as utilModule from '../util'; import { onRpcEndpointDegraded, onRpcEndpointUnavailable, @@ -14,8 +14,8 @@ describe('onRpcEndpointUnavailable', () => { Parameters >; let isPublicEndpointUrlMock: jest.SpyInstance< - ReturnType, - Parameters + ReturnType, + Parameters >; beforeEach(() => { @@ -24,10 +24,7 @@ describe('onRpcEndpointUnavailable', () => { 'shouldCreateRpcServiceEvents', ); - isPublicEndpointUrlMock = jest.spyOn( - networkUtilsModule, - 'isPublicEndpointUrl', - ); + isPublicEndpointUrlMock = jest.spyOn(utilModule, 'isPublicEndpointUrl'); }); it('calls shouldCreateRpcServiceEvents with the correct parameters', () => { @@ -170,8 +167,8 @@ describe('onRpcEndpointDegraded', () => { Parameters >; let isPublicEndpointUrlMock: jest.SpyInstance< - ReturnType, - Parameters + ReturnType, + Parameters >; beforeEach(() => { @@ -180,10 +177,7 @@ describe('onRpcEndpointDegraded', () => { 'shouldCreateRpcServiceEvents', ); - isPublicEndpointUrlMock = jest.spyOn( - networkUtilsModule, - 'isPublicEndpointUrl', - ); + isPublicEndpointUrlMock = jest.spyOn(utilModule, 'isPublicEndpointUrl'); }); it('calls shouldCreateRpcServiceEvents with the correct parameters', () => { diff --git a/app/scripts/lib/network-controller/messenger-action-handlers.ts b/app/scripts/lib/network-controller/messenger-action-handlers.ts index bc156bf665ad..4f1c8b0372a4 100644 --- a/app/scripts/lib/network-controller/messenger-action-handlers.ts +++ b/app/scripts/lib/network-controller/messenger-action-handlers.ts @@ -5,7 +5,7 @@ import { MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import { onlyKeepHost } from '../../../../shared/lib/only-keep-host'; -import { isPublicEndpointUrl } from '../../../../shared/lib/network-utils'; +import { isPublicEndpointUrl } from '../util'; import MetaMetricsController from '../../controllers/metametrics-controller'; import { shouldCreateRpcServiceEvents } from './utils'; diff --git a/app/scripts/lib/util.test.js b/app/scripts/lib/util.test.js index 67a7a97c313f..41318f85a6c0 100644 --- a/app/scripts/lib/util.test.js +++ b/app/scripts/lib/util.test.js @@ -30,6 +30,7 @@ import { extractRpcDomain, isKnownDomain, initializeRpcProviderDomains, + isPublicEndpointUrl, } from './util'; // Mock the module @@ -608,4 +609,26 @@ describe('app utils', () => { expect(extractRpcDomain('https://')).toBe('invalid'); }); }); + + describe('isPublicEndpointUrl', () => { + const MOCK_INFURA_PROJECT_ID = 'test-project-id'; + + it('should return true for Infura URLs', () => { + expect( + isPublicEndpointUrl( + `https://mainnet.infura.io/v3/${MOCK_INFURA_PROJECT_ID}`, + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(true); + }); + + it('should return false for unknown URLs', () => { + expect( + isPublicEndpointUrl( + 'https://unknown.example.com', + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(false); + }); + }); }); diff --git a/app/scripts/lib/util.ts b/app/scripts/lib/util.ts index d8708a7983dd..d35d0f070fa8 100644 --- a/app/scripts/lib/util.ts +++ b/app/scripts/lib/util.ts @@ -26,8 +26,10 @@ import { CHAIN_IDS, TEST_CHAINS } from '../../../shared/constants/network'; import { stripHexPrefix } from '../../../shared/modules/hexstring-utils'; import { getMethodDataAsync } from '../../../shared/lib/four-byte'; import { - initializeChainlistDomains, - isChainlistDomain, + getSafeChainsListFromCacheOnly, + getIsMetaMaskInfuraEndpointUrl, + getIsQuicknodeEndpointUrl, + KNOWN_CUSTOM_ENDPOINT_URLS, } from '../../../shared/lib/network-utils'; /** @@ -477,12 +479,120 @@ export function getConversionRatesForNativeAsset({ return conversionRateResult; } -// Re-export chainlist domain functions for backward compatibility -// These were moved to shared/lib/network-utils.ts -export { - initializeChainlistDomains as initializeRpcProviderDomains, - isChainlistDomain as isKnownDomain, -}; +// Cache for known domains from eth-chainlist +let knownDomainsSet: Set | null = null; +let initPromise: Promise | null = null; + +/** + * Extracts the hostname from a URL. + * + * @param url - The URL to extract the hostname from. + * @returns The lowercase hostname, or null if the URL is invalid. + */ +function extractHostname(url: string): string | null { + try { + const parsedUrl = new URL(url); + return parsedUrl.hostname.toLowerCase(); + } catch { + return null; + } +} + +/** + * Initialize the set of known domains from the eth-chainlist cache. + * This should be called once at startup in the background context. + * + * @returns A promise that resolves when initialization is complete. + */ +export async function initializeRpcProviderDomains(): Promise { + if (initPromise) { + return initPromise; + } + + initPromise = (async () => { + try { + const chainsList = await getSafeChainsListFromCacheOnly(); + knownDomainsSet = new Set(); + + for (const chain of chainsList) { + if (chain.rpc && Array.isArray(chain.rpc)) { + for (const rpcUrl of chain.rpc) { + const hostname = extractHostname(rpcUrl); + if (hostname) { + knownDomainsSet.add(hostname); + } + } + } + } + } catch (error) { + console.error('Error initializing known domains:', error); + knownDomainsSet = new Set(); + } + })(); + + return initPromise; +} + +/** + * Check if a domain is in the known domains list (eth-chainlist). + * + * @param domain - The domain to check. + * @returns True if the domain is found in the chainlist cache. + */ +export function isKnownDomain(domain: string): boolean { + if (!domain) { + return false; + } + return knownDomainsSet?.has(domain.toLowerCase()) ?? false; +} + +/** + * Check if an RPC endpoint URL's domain is defined in eth-chainlist. + * + * @param endpointUrl - The URL of the RPC endpoint. + * @returns True if the endpoint's domain is in the chainlist. + */ +function isChainlistEndpointUrl(endpointUrl: string): boolean { + const hostname = extractHostname(endpointUrl); + if (!hostname) { + return false; + } + return isKnownDomain(hostname); +} + +/** + * Some URLs that users add as networks refer to private servers, and we do not + * want to report these in Segment (or any other data collection service). This + * function returns whether the given RPC endpoint is safe to share. + * + * This function is only available in the background context where the chainlist + * domains are initialized. UI should call the background API method instead. + * + * @param endpointUrl - The URL of the endpoint. + * @param infuraProjectId - Our Infura project ID. + * @returns True if the endpoint URL is safe to share with external data + * collection services, false otherwise. + */ +export function isPublicEndpointUrl( + endpointUrl: string, + infuraProjectId: string, +): boolean { + const isMetaMaskInfuraEndpointUrl = getIsMetaMaskInfuraEndpointUrl( + endpointUrl, + infuraProjectId, + ); + const isQuicknodeEndpointUrl = getIsQuicknodeEndpointUrl(endpointUrl); + const isKnownCustomEndpointUrl = + KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl); + const isChainlistEndpoint = isChainlistEndpointUrl(endpointUrl); + + return ( + isMetaMaskInfuraEndpointUrl || + isQuicknodeEndpointUrl || + isKnownCustomEndpointUrl || + isChainlistEndpoint + ); +} /** * Extracts the domain from an RPC endpoint URL with privacy considerations @@ -518,12 +628,12 @@ export function extractRpcDomain( } } - // Use the provided test domains if available, otherwise use isChainlistDomain + // Use the provided test domains if available, otherwise use isKnownDomain if (knownDomainsForTesting) { if (knownDomainsForTesting.has(url.hostname.toLowerCase())) { return url.hostname.toLowerCase(); } - } else if (isChainlistDomain(url.hostname)) { + } else if (isKnownDomain(url.hostname)) { return url.hostname.toLowerCase(); } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 73fceb46c492..37842df32807 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -264,6 +264,7 @@ import { getMethodDataName, previousValueComparator, initializeRpcProviderDomains, + isPublicEndpointUrl, getPlatform, getBooleanFlag, } from './lib/util'; @@ -2498,6 +2499,8 @@ export default class MetamaskController extends EventEmitter { getProviderConfig({ metamask: this.networkController.state, }), + isPublicEndpointUrl: (endpointUrl) => + isPublicEndpointUrl(endpointUrl, this.opts.infuraProjectId), grantPermissionsIncremental: this.permissionController.grantPermissionsIncremental.bind( this.permissionController, diff --git a/shared/lib/network-utils.test.ts b/shared/lib/network-utils.test.ts index 26e656ed784f..c928d01ed9d2 100644 --- a/shared/lib/network-utils.test.ts +++ b/shared/lib/network-utils.test.ts @@ -1,19 +1,8 @@ -import { BUILT_IN_CUSTOM_NETWORKS_RPC } from '@metamask/controller-utils'; - -import { - FEATURED_RPCS, - QUICKNODE_ENDPOINT_URLS_BY_INFURA_NETWORK_NAME, -} from '../constants/network'; +import { QUICKNODE_ENDPOINT_URLS_BY_INFURA_NETWORK_NAME } from '../constants/network'; import { getIsMetaMaskInfuraEndpointUrl, getIsQuicknodeEndpointUrl, - isPublicEndpointUrl, - initializeChainlistDomains, - isChainlistDomain, - isChainlistEndpointUrl, - resetChainlistDomainsCache, } from './network-utils'; -import * as storageHelpers from './storage-helpers'; jest.mock('../constants/network', () => ({ FEATURED_RPCS: [ @@ -59,8 +48,6 @@ jest.mock('@metamask/controller-utils', () => ({ }, })); -const MOCK_METAMASK_INFURA_PROJECT_ID = 'metamask-infura-project-id'; - describe('getIsMetaMaskInfuraEndpointUrl', () => { it('returns true given an Infura v3 URL with the MetaMask API key at the end', () => { expect( @@ -163,266 +150,3 @@ describe('getIsQuicknodeEndpointUrl', () => { expect(getIsQuicknodeEndpointUrl('')).toBe(false); }); }); - -describe('isPublicEndpointUrl', () => { - it('returns true for Infura URLs', () => { - expect( - isPublicEndpointUrl( - `https://mainnet.infura.io/v3/${MOCK_METAMASK_INFURA_PROJECT_ID}`, - MOCK_METAMASK_INFURA_PROJECT_ID, - ), - ).toBe(true); - }); - - it('returns true for Quicknode URLs', () => { - const quicknodeUrl = - QUICKNODE_ENDPOINT_URLS_BY_INFURA_NETWORK_NAME['ethereum-mainnet'](); - expect( - // We can assume this is set. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - isPublicEndpointUrl(quicknodeUrl!, MOCK_METAMASK_INFURA_PROJECT_ID), - ).toBe(true); - }); - - it('returns true for featured RPC endpoints', () => { - const featuredUrl = FEATURED_RPCS[0].rpcEndpoints[0].url; - expect( - isPublicEndpointUrl(featuredUrl, MOCK_METAMASK_INFURA_PROJECT_ID), - ).toBe(true); - }); - - it('returns true for built-in custom endpoints', () => { - const builtInUrl = Object.values(BUILT_IN_CUSTOM_NETWORKS_RPC)[0]; - expect( - isPublicEndpointUrl(builtInUrl, MOCK_METAMASK_INFURA_PROJECT_ID), - ).toBe(true); - }); - - it('returns false for unknown URLs when chainlist is not initialized', () => { - resetChainlistDomainsCache(); - expect( - isPublicEndpointUrl( - 'https://unknown.example.com', - MOCK_METAMASK_INFURA_PROJECT_ID, - ), - ).toBe(false); - }); - - it('returns true for chainlist domains after initialization', async () => { - resetChainlistDomainsCache(); - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [ - { - name: 'Test Chain', - chainId: 1, - rpc: ['https://chainlist-rpc.example.com/rpc'], - }, - ], - }); - - await initializeChainlistDomains(); - - expect( - isPublicEndpointUrl( - 'https://chainlist-rpc.example.com/v1/abc123', - MOCK_METAMASK_INFURA_PROJECT_ID, - ), - ).toBe(true); - }); -}); - -describe('initializeChainlistDomains', () => { - beforeEach(() => { - resetChainlistDomainsCache(); - jest.clearAllMocks(); - }); - - it('initializes domains from cached chains list', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [ - { - name: 'Ethereum Mainnet', - chainId: 1, - rpc: [ - 'https://mainnet.infura.io/v3/key', - 'https://eth-mainnet.alchemyapi.io/v2/key', - ], - }, - { - name: 'Polygon', - chainId: 137, - rpc: ['https://polygon-rpc.com'], - }, - ], - }); - - await initializeChainlistDomains(); - - expect(isChainlistDomain('mainnet.infura.io')).toBe(true); - expect(isChainlistDomain('eth-mainnet.alchemyapi.io')).toBe(true); - expect(isChainlistDomain('polygon-rpc.com')).toBe(true); - expect(isChainlistDomain('unknown.com')).toBe(false); - }); - - it('handles empty chains list', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [], - }); - - await initializeChainlistDomains(); - - expect(isChainlistDomain('any-domain.com')).toBe(false); - }); - - it('handles missing cache', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue(undefined); - - await initializeChainlistDomains(); - - expect(isChainlistDomain('any-domain.com')).toBe(false); - }); - - it('handles chains without rpc field', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [ - { - name: 'Chain Without RPC', - chainId: 999, - }, - ], - }); - - await initializeChainlistDomains(); - - expect(isChainlistDomain('any-domain.com')).toBe(false); - }); - - it('skips invalid URLs in rpc list', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [ - { - name: 'Chain With Invalid RPC', - chainId: 999, - rpc: ['not-a-valid-url', 'https://valid-rpc.com'], - }, - ], - }); - - await initializeChainlistDomains(); - - expect(isChainlistDomain('valid-rpc.com')).toBe(true); - }); - - it('only fetches from storage once on subsequent calls', async () => { - const getStorageItemSpy = jest - .spyOn(storageHelpers, 'getStorageItem') - .mockResolvedValue({ - cachedResponse: [], - }); - - await initializeChainlistDomains(); - await initializeChainlistDomains(); - await initializeChainlistDomains(); - - // Storage should only be accessed once despite multiple calls - expect(getStorageItemSpy).toHaveBeenCalledTimes(1); - }); -}); - -describe('isChainlistDomain', () => { - beforeEach(() => { - resetChainlistDomainsCache(); - }); - - it('returns false when not initialized', () => { - expect(isChainlistDomain('any-domain.com')).toBe(false); - }); - - it('returns false for empty domain', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [{ name: 'Test', chainId: 1, rpc: ['https://test.com'] }], - }); - await initializeChainlistDomains(); - - expect(isChainlistDomain('')).toBe(false); - }); - - it('is case insensitive', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [ - { name: 'Test', chainId: 1, rpc: ['https://Test-RPC.COM/path'] }, - ], - }); - await initializeChainlistDomains(); - - expect(isChainlistDomain('test-rpc.com')).toBe(true); - expect(isChainlistDomain('TEST-RPC.COM')).toBe(true); - expect(isChainlistDomain('Test-RPC.com')).toBe(true); - }); -}); - -describe('isChainlistEndpointUrl', () => { - beforeEach(() => { - resetChainlistDomainsCache(); - }); - - it('returns false when not initialized', () => { - expect(isChainlistEndpointUrl('https://any-domain.com/rpc')).toBe(false); - }); - - it('returns true for URLs with chainlist domains', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [ - { name: 'Test', chainId: 1, rpc: ['https://chainlist-domain.com'] }, - ], - }); - await initializeChainlistDomains(); - - expect( - isChainlistEndpointUrl('https://chainlist-domain.com/v1/abc123'), - ).toBe(true); - expect(isChainlistEndpointUrl('https://chainlist-domain.com')).toBe(true); - }); - - it('returns false for URLs with unknown domains', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [ - { name: 'Test', chainId: 1, rpc: ['https://known-domain.com'] }, - ], - }); - await initializeChainlistDomains(); - - expect(isChainlistEndpointUrl('https://unknown-domain.com/rpc')).toBe( - false, - ); - }); - - it('returns false for invalid URLs', () => { - expect(isChainlistEndpointUrl('not-a-url')).toBe(false); - expect(isChainlistEndpointUrl('')).toBe(false); - }); -}); - -describe('resetChainlistDomainsCache', () => { - it('clears the cache and allows reinitialization', async () => { - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [ - { name: 'First', chainId: 1, rpc: ['https://first-domain.com'] }, - ], - }); - await initializeChainlistDomains(); - expect(isChainlistDomain('first-domain.com')).toBe(true); - - resetChainlistDomainsCache(); - expect(isChainlistDomain('first-domain.com')).toBe(false); - - jest.spyOn(storageHelpers, 'getStorageItem').mockResolvedValue({ - cachedResponse: [ - { name: 'Second', chainId: 2, rpc: ['https://second-domain.com'] }, - ], - }); - await initializeChainlistDomains(); - expect(isChainlistDomain('second-domain.com')).toBe(true); - expect(isChainlistDomain('first-domain.com')).toBe(false); - }); -}); diff --git a/shared/lib/network-utils.ts b/shared/lib/network-utils.ts index fa572b33b71d..bacd4cdc05c5 100644 --- a/shared/lib/network-utils.ts +++ b/shared/lib/network-utils.ts @@ -9,10 +9,6 @@ import { getStorageItem } from './storage-helpers'; const cacheKey = `cachedFetch:${CHAIN_SPEC_URL}`; -// Cache for known domains from eth-chainlist -let knownDomainsSet: Set | null = null; -let initPromise: Promise | null = null; - type ChainInfo = { name: string; shortName?: string; @@ -91,121 +87,6 @@ export function getIsQuicknodeEndpointUrl(endpointUrl: string): boolean { } /** - * Some URLs that users add as networks refer to private servers, and we do not - * want to report these in Segment (or any other data collection service). This - * function returns whether the given RPC endpoint is safe to share. - * - * @param endpointUrl - The URL of the endpoint. - * @param infuraProjectId - Our Infura project ID. - * @returns True if the endpoint URL is safe to share with external data - * collection services, false otherwise. - */ -export function isPublicEndpointUrl( - endpointUrl: string, - infuraProjectId: string, -) { - const isMetaMaskInfuraEndpointUrl = getIsMetaMaskInfuraEndpointUrl( - endpointUrl, - infuraProjectId, - ); - const isQuicknodeEndpointUrl = getIsQuicknodeEndpointUrl(endpointUrl); - const isKnownCustomEndpointUrl = - KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl); - const isChainlistEndpoint = isChainlistEndpointUrl(endpointUrl); - - return ( - isMetaMaskInfuraEndpointUrl || - isQuicknodeEndpointUrl || - isKnownCustomEndpointUrl || - isChainlistEndpoint - ); -} - -/** - * Extracts the hostname from a URL. - * - * @param url - The URL to extract the hostname from. - * @returns The lowercase hostname, or null if the URL is invalid. - */ -function extractHostname(url: string): string | null { - try { - const parsedUrl = new URL(url); - return parsedUrl.hostname.toLowerCase(); - } catch { - return null; - } -} - -/** - * Initialize the set of known domains from the eth-chainlist cache. - * This should be called once at startup in the background context only. - * The UI should NOT call this to avoid the ~300KB memory footprint. - * When not initialized, isChainlistDomain() returns false, which means - * isPublicEndpointUrl() falls back to checking Infura, Quicknode, and - * known custom endpoints only. - * - * @returns A promise that resolves when initialization is complete. - */ -export async function initializeChainlistDomains(): Promise { - if (initPromise) { - return initPromise; - } - - initPromise = (async () => { - try { - const chainsList = await getSafeChainsListFromCacheOnly(); - knownDomainsSet = new Set(); - - for (const chain of chainsList) { - if (chain.rpc && Array.isArray(chain.rpc)) { - for (const rpcUrl of chain.rpc) { - const hostname = extractHostname(rpcUrl); - if (hostname) { - knownDomainsSet.add(hostname); - } - } - } - } - } catch (error) { - console.error('Error initializing chainlist domains:', error); - knownDomainsSet = new Set(); - } - })(); - - return initPromise; -} - -/** - * Check if a domain is in the eth-chainlist (cached). - * - * @param domain - The domain to check. - * @returns True if the domain is found in the chainlist cache. - */ -export function isChainlistDomain(domain: string): boolean { - if (!domain) { - return false; - } - return knownDomainsSet?.has(domain.toLowerCase()) ?? false; -} - -/** - * Check if an RPC endpoint URL's domain is defined in eth-chainlist. - * - * @param endpointUrl - The URL of the RPC endpoint. - * @returns True if the endpoint's domain is in the chainlist. - */ -export function isChainlistEndpointUrl(endpointUrl: string): boolean { - const hostname = extractHostname(endpointUrl); - if (!hostname) { - return false; - } - return isChainlistDomain(hostname); -} - -/** - * Resets the chainlist domains cache. Useful for testing. + * The list of known unofficial endpoint URLs. */ -export function resetChainlistDomainsCache(): void { - knownDomainsSet = null; - initPromise = null; -} +export { KNOWN_CUSTOM_ENDPOINT_URLS }; diff --git a/ui/hooks/useNetworkConnectionBanner.test.ts b/ui/hooks/useNetworkConnectionBanner.test.ts index ad717b0ac9d5..46b6c3414aa7 100644 --- a/ui/hooks/useNetworkConnectionBanner.test.ts +++ b/ui/hooks/useNetworkConnectionBanner.test.ts @@ -65,6 +65,13 @@ jest.mock('../../shared/modules/selectors/networks', () => { }; }); +jest.mock('../store/background-connection', () => { + return { + ...jest.requireActual('../store/background-connection'), + submitRequestToBackground: jest.fn().mockResolvedValue(true), + }; +}); + const mockSelectFirstUnavailableEvmNetwork = jest.mocked( selectFirstUnavailableEvmNetwork, ); @@ -186,7 +193,7 @@ describe('useNetworkConnectionBanner', () => { }); }); - it('creates a MetaMetrics event to capture that the status changed', () => { + it('creates a MetaMetrics event to capture that the status changed', async () => { mockSelectFirstUnavailableEvmNetwork.mockReturnValue({ networkName: 'Ethereum Mainnet', networkClientId: 'mainnet', @@ -204,8 +211,10 @@ describe('useNetworkConnectionBanner', () => { undefined, () => mockTrackEvent, ); - act(() => { + await act(async () => { jest.advanceTimersByTime(5000); + // Flush microtask queue to allow async trackNetworkBannerEvent to complete + await Promise.resolve(); }); expect(mockTrackEvent).toHaveBeenCalledWith({ @@ -293,7 +302,7 @@ describe('useNetworkConnectionBanner', () => { }); }); - it('creates a MetaMetrics event to capture that the status changed', () => { + it('creates a MetaMetrics event to capture that the status changed', async () => { mockSelectFirstUnavailableEvmNetwork.mockReturnValue({ networkName: 'Ethereum Mainnet', networkClientId: 'mainnet', @@ -318,8 +327,10 @@ describe('useNetworkConnectionBanner', () => { undefined, () => mockTrackEvent, ); - act(() => { + await act(async () => { jest.advanceTimersByTime(25000); + // Flush microtask queue to allow async trackNetworkBannerEvent to complete + await Promise.resolve(); }); expect(mockTrackEvent).toHaveBeenCalledWith({ diff --git a/ui/hooks/useNetworkConnectionBanner.ts b/ui/hooks/useNetworkConnectionBanner.ts index b3423ae9bcc3..555502948194 100644 --- a/ui/hooks/useNetworkConnectionBanner.ts +++ b/ui/hooks/useNetworkConnectionBanner.ts @@ -12,10 +12,9 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../shared/constants/metametrics'; -import { infuraProjectId } from '../../shared/constants/network'; import { getNetworkConfigurationsByChainId } from '../../shared/modules/selectors/networks'; import { onlyKeepHost } from '../../shared/lib/only-keep-host'; -import { isPublicEndpointUrl } from '../../shared/lib/network-utils'; +import { submitRequestToBackground } from '../store/background-connection'; import { NetworkConnectionBanner } from '../../shared/constants/app-state'; import { setShowInfuraSwitchToast } from '../components/app/toast-master/utils'; @@ -76,7 +75,7 @@ export const useNetworkConnectionBanner = }, [clearDegradedTimer, clearUnavailableTimer]); const trackNetworkBannerEvent = useCallback( - ({ + async ({ bannerType, eventName, networkClientId, @@ -85,13 +84,6 @@ export const useNetworkConnectionBanner = eventName: string; networkClientId: string; }) => { - if (!infuraProjectId) { - console.warn( - 'Infura project ID not found, cannot track network banner event', - ); - return; - } - let foundNetwork: { chainId: Hex; url: string } | undefined; for (const networkConfiguration of Object.values( networkConfigurationsByChainId, @@ -116,9 +108,11 @@ export const useNetworkConnectionBanner = const rpcUrl = foundNetwork.url; const chainIdAsDecimal = hexToNumber(foundNetwork.chainId); - const sanitizedRpcUrl = isPublicEndpointUrl(rpcUrl, infuraProjectId) - ? onlyKeepHost(rpcUrl) - : 'custom'; + const isPublic = await submitRequestToBackground( + 'isPublicEndpointUrl', + [rpcUrl], + ); + const sanitizedRpcUrl = isPublic ? onlyKeepHost(rpcUrl) : 'custom'; trackEvent({ category: MetaMetricsEventCategory.Network, diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx index 8a2d1c1bf273..b540b270c37a 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx @@ -27,7 +27,7 @@ import { isSafeChainId, } from '../../../../../shared/modules/network.utils'; import { jsonRpcRequest } from '../../../../../shared/modules/rpc.utils'; -import { isPublicEndpointUrl } from '../../../../../shared/lib/network-utils'; +import { submitRequestToBackground } from '../../../../store/background-connection'; import { MetaMetricsContext } from '../../../../contexts/metametrics'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; @@ -326,10 +326,20 @@ export const NetworksForm = ({ const chainIdAsDecimal = hexToNumber(chainIdHex); - const sanitizeRpcUrl = (url: string) => - isPublicEndpointUrl(url, infuraProjectId ?? '') - ? onlyKeepHost(url) - : 'custom'; + const sanitizeRpcUrl = async (url: string) => { + const isPublic = await submitRequestToBackground( + 'isPublicEndpointUrl', + [url], + ); + return isPublic ? onlyKeepHost(url) : 'custom'; + }; + + const [fromRpcDomain, toRpcDomain] = await Promise.all([ + oldRpcEndpoint?.url + ? sanitizeRpcUrl(oldRpcEndpoint.url) + : Promise.resolve('unknown'), + sanitizeRpcUrl(newRpcEndpoint.url), + ]); trackEvent({ category: MetaMetricsEventCategory.Network, @@ -338,10 +348,8 @@ export const NetworksForm = ({ /* eslint-disable @typescript-eslint/naming-convention */ properties: { chain_id_caip: `eip155:${chainIdAsDecimal}`, - from_rpc_domain: oldRpcEndpoint?.url - ? sanitizeRpcUrl(oldRpcEndpoint.url) - : 'unknown', - to_rpc_domain: sanitizeRpcUrl(newRpcEndpoint.url), + from_rpc_domain: fromRpcDomain, + to_rpc_domain: toRpcDomain, }, /* eslint-enable @typescript-eslint/naming-convention */ }); From a913df89fd7f875b1f5cebb1cd4c26c611308226 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 29 Jan 2026 20:48:57 +0100 Subject: [PATCH 03/10] fix: address pr feedback --- app/scripts/lib/util.test.js | 76 +++++++++++++++++ app/scripts/lib/util.ts | 81 ++++++++++++++++--- .../networks-form/networks-form.test.js | 12 +++ 3 files changed, 159 insertions(+), 10 deletions(-) diff --git a/app/scripts/lib/util.test.js b/app/scripts/lib/util.test.js index 41318f85a6c0..a1c551b9856b 100644 --- a/app/scripts/lib/util.test.js +++ b/app/scripts/lib/util.test.js @@ -630,5 +630,81 @@ describe('app utils', () => { ), ).toBe(false); }); + + describe('localhost and private IP addresses', () => { + it('should return false for localhost', () => { + expect( + isPublicEndpointUrl('http://localhost:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + }); + + it('should return false for 127.0.0.1', () => { + expect( + isPublicEndpointUrl('http://127.0.0.1:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + }); + + it('should return false for 127.x.x.x loopback addresses', () => { + expect( + isPublicEndpointUrl('http://127.1.2.3:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + }); + + it('should return false for IPv6 loopback ::1', () => { + expect( + isPublicEndpointUrl('http://[::1]:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + }); + + it('should return false for private IP 10.x.x.x', () => { + expect( + isPublicEndpointUrl('http://10.0.0.1:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + expect( + isPublicEndpointUrl( + 'http://10.255.255.255:8545', + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(false); + }); + + it('should return false for private IP 172.16.x.x - 172.31.x.x', () => { + expect( + isPublicEndpointUrl('http://172.16.0.1:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); + expect( + isPublicEndpointUrl( + 'http://172.31.255.255:8545', + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(false); + }); + + it('should return true for non-private 172.x.x.x addresses', () => { + // 172.15.x.x is not in the private range + expect( + isPublicEndpointUrl('http://172.15.0.1:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); // Still false because it's not a known public endpoint + // 172.32.x.x is not in the private range + expect( + isPublicEndpointUrl('http://172.32.0.1:8545', MOCK_INFURA_PROJECT_ID), + ).toBe(false); // Still false because it's not a known public endpoint + }); + + it('should return false for private IP 192.168.x.x', () => { + expect( + isPublicEndpointUrl( + 'http://192.168.0.1:8545', + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(false); + expect( + isPublicEndpointUrl( + 'http://192.168.255.255:8545', + MOCK_INFURA_PROJECT_ID, + ), + ).toBe(false); + }); + }); }); }); diff --git a/app/scripts/lib/util.ts b/app/scripts/lib/util.ts index d35d0f070fa8..b84fd81844e9 100644 --- a/app/scripts/lib/util.ts +++ b/app/scripts/lib/util.ts @@ -479,7 +479,7 @@ export function getConversionRatesForNativeAsset({ return conversionRateResult; } -// Cache for known domains from eth-chainlist +// Cache for known domains let knownDomainsSet: Set | null = null; let initPromise: Promise | null = null; @@ -499,7 +499,7 @@ function extractHostname(url: string): string | null { } /** - * Initialize the set of known domains from the eth-chainlist cache. + * Initialize the set of known domains from the safe chainlist cache. * This should be called once at startup in the background context. * * @returns A promise that resolves when initialization is complete. @@ -534,7 +534,7 @@ export async function initializeRpcProviderDomains(): Promise { } /** - * Check if a domain is in the known domains list (eth-chainlist). + * Check if a domain is in the known domains list. * * @param domain - The domain to check. * @returns True if the domain is found in the chainlist cache. @@ -547,12 +547,13 @@ export function isKnownDomain(domain: string): boolean { } /** - * Check if an RPC endpoint URL's domain is defined in eth-chainlist. + * Check if an RPC endpoint URL has a "known", domain, i.e. the domain is listed + * in a public registry. * * @param endpointUrl - The URL of the RPC endpoint. - * @returns True if the endpoint's domain is in the chainlist. + * @returns True if the endpoint's domain is listed in a public registry. */ -function isChainlistEndpointUrl(endpointUrl: string): boolean { +function isKnownEndpointUrl(endpointUrl: string): boolean { const hostname = extractHostname(endpointUrl); if (!hostname) { return false; @@ -560,13 +561,66 @@ function isChainlistEndpointUrl(endpointUrl: string): boolean { return isKnownDomain(hostname); } +/** + * Check if a hostname is a localhost or private/local IP address. + * These should never be considered "public" endpoints even if they appear in chainlist. + * + * @param hostname - The hostname to check. + * @returns True if the hostname is localhost or a private IP address. + */ +function isLocalhostOrPrivateIP(hostname: string): boolean { + if (!hostname) { + return false; + } + + const lowerHostname = hostname.toLowerCase(); + + // Check for localhost + if (lowerHostname === 'localhost') { + return true; + } + + // Check for IPv4 loopback (127.x.x.x) + if (lowerHostname.startsWith('127.')) { + return true; + } + + // Check for IPv6 loopback + if (lowerHostname === '::1' || lowerHostname === '[::1]') { + return true; + } + + // Check for private IPv4 ranges + // 10.0.0.0 - 10.255.255.255 + if (lowerHostname.startsWith('10.')) { + return true; + } + + // 172.16.0.0 - 172.31.255.255 + const match172 = lowerHostname.match(/^172\.(\d+)\./u); + if (match172) { + const secondOctet = parseInt(match172[1], 10); + if (secondOctet >= 16 && secondOctet <= 31) { + return true; + } + } + + // 192.168.0.0 - 192.168.255.255 + if (lowerHostname.startsWith('192.168.')) { + return true; + } + + return false; +} + /** * Some URLs that users add as networks refer to private servers, and we do not * want to report these in Segment (or any other data collection service). This * function returns whether the given RPC endpoint is safe to share. * - * This function is only available in the background context where the chainlist - * domains are initialized. UI should call the background API method instead. + * This function is only available in the background context where the set of + * known domains is initialized. UI should call the background API method + * instead. * * @param endpointUrl - The URL of the endpoint. * @param infuraProjectId - Our Infura project ID. @@ -577,6 +631,13 @@ export function isPublicEndpointUrl( endpointUrl: string, infuraProjectId: string, ): boolean { + // First, check if this is a localhost or private IP address. + // These should never be considered "public" even if they appear in chainlist. + const hostname = extractHostname(endpointUrl); + if (hostname && isLocalhostOrPrivateIP(hostname)) { + return false; + } + const isMetaMaskInfuraEndpointUrl = getIsMetaMaskInfuraEndpointUrl( endpointUrl, infuraProjectId, @@ -584,13 +645,13 @@ export function isPublicEndpointUrl( const isQuicknodeEndpointUrl = getIsQuicknodeEndpointUrl(endpointUrl); const isKnownCustomEndpointUrl = KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl); - const isChainlistEndpoint = isChainlistEndpointUrl(endpointUrl); + const isKnownEndpoint = isKnownEndpointUrl(endpointUrl); return ( isMetaMaskInfuraEndpointUrl || isQuicknodeEndpointUrl || isKnownCustomEndpointUrl || - isChainlistEndpoint + isKnownEndpoint ); } diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.test.js b/ui/pages/settings/networks-tab/networks-form/networks-form.test.js index 10af58ba95b1..7107e925fe6c 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.test.js +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.test.js @@ -30,6 +30,18 @@ jest.mock('../../../../../ui/store/actions', () => ({ .mockReturnValue(jest.fn().mockResolvedValue()), })); +jest.mock('../../../../store/background-connection', () => ({ + ...jest.requireActual('../../../../store/background-connection'), + submitRequestToBackground: jest.fn().mockImplementation((method, args) => { + if (method === 'isPublicEndpointUrl') { + const url = args[0]; + // Return true for Infura URLs, false for everything else + return Promise.resolve(url?.includes('infura.io') ?? false); + } + return Promise.resolve(); + }), +})); + const renderComponent = (props) => { const store = configureMockStore([thunk])({ metamask: { From 7d1d5b5e5aa17e7b7a573b7fe5f2e597d20fbf13 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 29 Jan 2026 21:01:46 +0100 Subject: [PATCH 04/10] fix: filter out private ips --- app/scripts/lib/util.ts | 122 ++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 62 deletions(-) diff --git a/app/scripts/lib/util.ts b/app/scripts/lib/util.ts index b84fd81844e9..d64e2ec885eb 100644 --- a/app/scripts/lib/util.ts +++ b/app/scripts/lib/util.ts @@ -498,9 +498,63 @@ function extractHostname(url: string): string | null { } } +/** + * Check if a hostname is a localhost or private/local IP address. + * These should never be considered "public" endpoints even if they appear in chainlist. + * + * @param hostname - The hostname to check. + * @returns True if the hostname is localhost or a private IP address. + */ +function isLocalhostOrPrivateIP(hostname: string): boolean { + if (!hostname) { + return false; + } + + const lowerHostname = hostname.toLowerCase(); + + // Check for localhost + if (lowerHostname === 'localhost') { + return true; + } + + // Check for IPv4 loopback (127.x.x.x) + if (lowerHostname.startsWith('127.')) { + return true; + } + + // Check for IPv6 loopback + if (lowerHostname === '::1' || lowerHostname === '[::1]') { + return true; + } + + // Check for private IPv4 ranges + // 10.0.0.0 - 10.255.255.255 + if (lowerHostname.startsWith('10.')) { + return true; + } + + // 172.16.0.0 - 172.31.255.255 + const match172 = lowerHostname.match(/^172\.(\d+)\./u); + if (match172) { + const secondOctet = parseInt(match172[1], 10); + if (secondOctet >= 16 && secondOctet <= 31) { + return true; + } + } + + // 192.168.0.0 - 192.168.255.255 + if (lowerHostname.startsWith('192.168.')) { + return true; + } + + return false; +} + /** * Initialize the set of known domains from the safe chainlist cache. * This should be called once at startup in the background context. + * Localhost and private IP addresses are filtered out to prevent leaking + * private network information to analytics. * * @returns A promise that resolves when initialization is complete. */ @@ -518,7 +572,9 @@ export async function initializeRpcProviderDomains(): Promise { if (chain.rpc && Array.isArray(chain.rpc)) { for (const rpcUrl of chain.rpc) { const hostname = extractHostname(rpcUrl); - if (hostname) { + // Filter out localhost and private IPs - these should never be + // considered "public" even if they appear in chainlist + if (hostname && !isLocalhostOrPrivateIP(hostname)) { knownDomainsSet.add(hostname); } } @@ -547,8 +603,9 @@ export function isKnownDomain(domain: string): boolean { } /** - * Check if an RPC endpoint URL has a "known", domain, i.e. the domain is listed - * in a public registry. + * Check if an RPC endpoint URL has a "known" domain, i.e. the domain is listed + * in a public registry. Localhost and private IPs are filtered out during + * initialization of the known domains set. * * @param endpointUrl - The URL of the RPC endpoint. * @returns True if the endpoint's domain is listed in a public registry. @@ -561,58 +618,6 @@ function isKnownEndpointUrl(endpointUrl: string): boolean { return isKnownDomain(hostname); } -/** - * Check if a hostname is a localhost or private/local IP address. - * These should never be considered "public" endpoints even if they appear in chainlist. - * - * @param hostname - The hostname to check. - * @returns True if the hostname is localhost or a private IP address. - */ -function isLocalhostOrPrivateIP(hostname: string): boolean { - if (!hostname) { - return false; - } - - const lowerHostname = hostname.toLowerCase(); - - // Check for localhost - if (lowerHostname === 'localhost') { - return true; - } - - // Check for IPv4 loopback (127.x.x.x) - if (lowerHostname.startsWith('127.')) { - return true; - } - - // Check for IPv6 loopback - if (lowerHostname === '::1' || lowerHostname === '[::1]') { - return true; - } - - // Check for private IPv4 ranges - // 10.0.0.0 - 10.255.255.255 - if (lowerHostname.startsWith('10.')) { - return true; - } - - // 172.16.0.0 - 172.31.255.255 - const match172 = lowerHostname.match(/^172\.(\d+)\./u); - if (match172) { - const secondOctet = parseInt(match172[1], 10); - if (secondOctet >= 16 && secondOctet <= 31) { - return true; - } - } - - // 192.168.0.0 - 192.168.255.255 - if (lowerHostname.startsWith('192.168.')) { - return true; - } - - return false; -} - /** * Some URLs that users add as networks refer to private servers, and we do not * want to report these in Segment (or any other data collection service). This @@ -631,13 +636,6 @@ export function isPublicEndpointUrl( endpointUrl: string, infuraProjectId: string, ): boolean { - // First, check if this is a localhost or private IP address. - // These should never be considered "public" even if they appear in chainlist. - const hostname = extractHostname(endpointUrl); - if (hostname && isLocalhostOrPrivateIP(hostname)) { - return false; - } - const isMetaMaskInfuraEndpointUrl = getIsMetaMaskInfuraEndpointUrl( endpointUrl, infuraProjectId, From ec38a93adc6f7fe950d94362910a943c4664a065 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 29 Jan 2026 21:11:44 +0100 Subject: [PATCH 05/10] fix: filter out localhost and IP addresses --- app/scripts/lib/util.test.js | 55 +++++++----------------------------- app/scripts/lib/util.ts | 43 +++++++++------------------- 2 files changed, 23 insertions(+), 75 deletions(-) diff --git a/app/scripts/lib/util.test.js b/app/scripts/lib/util.test.js index a1c551b9856b..b59de86fe965 100644 --- a/app/scripts/lib/util.test.js +++ b/app/scripts/lib/util.test.js @@ -631,76 +631,41 @@ describe('app utils', () => { ).toBe(false); }); - describe('localhost and private IP addresses', () => { + describe('localhost and IP addresses', () => { it('should return false for localhost', () => { expect( isPublicEndpointUrl('http://localhost:8545', MOCK_INFURA_PROJECT_ID), ).toBe(false); }); - it('should return false for 127.0.0.1', () => { + it('should return false for any IPv4 address', () => { + // Loopback expect( isPublicEndpointUrl('http://127.0.0.1:8545', MOCK_INFURA_PROJECT_ID), ).toBe(false); - }); - - it('should return false for 127.x.x.x loopback addresses', () => { - expect( - isPublicEndpointUrl('http://127.1.2.3:8545', MOCK_INFURA_PROJECT_ID), - ).toBe(false); - }); - - it('should return false for IPv6 loopback ::1', () => { - expect( - isPublicEndpointUrl('http://[::1]:8545', MOCK_INFURA_PROJECT_ID), - ).toBe(false); - }); - - it('should return false for private IP 10.x.x.x', () => { + // Private ranges expect( isPublicEndpointUrl('http://10.0.0.1:8545', MOCK_INFURA_PROJECT_ID), ).toBe(false); expect( isPublicEndpointUrl( - 'http://10.255.255.255:8545', + 'http://192.168.1.1:8545', MOCK_INFURA_PROJECT_ID, ), ).toBe(false); - }); - - it('should return false for private IP 172.16.x.x - 172.31.x.x', () => { - expect( - isPublicEndpointUrl('http://172.16.0.1:8545', MOCK_INFURA_PROJECT_ID), - ).toBe(false); + // Public IPs should also return false (public providers use domain names) expect( - isPublicEndpointUrl( - 'http://172.31.255.255:8545', - MOCK_INFURA_PROJECT_ID, - ), + isPublicEndpointUrl('http://8.8.8.8:8545', MOCK_INFURA_PROJECT_ID), ).toBe(false); }); - it('should return true for non-private 172.x.x.x addresses', () => { - // 172.15.x.x is not in the private range - expect( - isPublicEndpointUrl('http://172.15.0.1:8545', MOCK_INFURA_PROJECT_ID), - ).toBe(false); // Still false because it's not a known public endpoint - // 172.32.x.x is not in the private range + it('should return false for any IPv6 address', () => { expect( - isPublicEndpointUrl('http://172.32.0.1:8545', MOCK_INFURA_PROJECT_ID), - ).toBe(false); // Still false because it's not a known public endpoint - }); - - it('should return false for private IP 192.168.x.x', () => { - expect( - isPublicEndpointUrl( - 'http://192.168.0.1:8545', - MOCK_INFURA_PROJECT_ID, - ), + isPublicEndpointUrl('http://[::1]:8545', MOCK_INFURA_PROJECT_ID), ).toBe(false); expect( isPublicEndpointUrl( - 'http://192.168.255.255:8545', + 'http://[2001:db8::1]:8545', MOCK_INFURA_PROJECT_ID, ), ).toBe(false); diff --git a/app/scripts/lib/util.ts b/app/scripts/lib/util.ts index d64e2ec885eb..b75742c4e740 100644 --- a/app/scripts/lib/util.ts +++ b/app/scripts/lib/util.ts @@ -499,13 +499,14 @@ function extractHostname(url: string): string | null { } /** - * Check if a hostname is a localhost or private/local IP address. + * Check if a hostname is localhost or an IP address. + * Public RPC providers use domain names, not raw IP addresses. * These should never be considered "public" endpoints even if they appear in chainlist. * * @param hostname - The hostname to check. - * @returns True if the hostname is localhost or a private IP address. + * @returns True if the hostname is localhost or an IP address (v4 or v6). */ -function isLocalhostOrPrivateIP(hostname: string): boolean { +function isLocalhostOrIPAddress(hostname: string): boolean { if (!hostname) { return false; } @@ -517,33 +518,16 @@ function isLocalhostOrPrivateIP(hostname: string): boolean { return true; } - // Check for IPv4 loopback (127.x.x.x) - if (lowerHostname.startsWith('127.')) { + // Check for IPv4 address (e.g., 192.168.1.1, 127.0.0.1, 8.8.8.8) + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/u; + if (ipv4Regex.test(lowerHostname)) { return true; } - // Check for IPv6 loopback - if (lowerHostname === '::1' || lowerHostname === '[::1]') { - return true; - } - - // Check for private IPv4 ranges - // 10.0.0.0 - 10.255.255.255 - if (lowerHostname.startsWith('10.')) { - return true; - } - - // 172.16.0.0 - 172.31.255.255 - const match172 = lowerHostname.match(/^172\.(\d+)\./u); - if (match172) { - const secondOctet = parseInt(match172[1], 10); - if (secondOctet >= 16 && secondOctet <= 31) { - return true; - } - } - - // 192.168.0.0 - 192.168.255.255 - if (lowerHostname.startsWith('192.168.')) { + // Check for IPv6 address (with or without brackets) + // Matches: ::1, [::1], 2001:db8::1, [2001:db8::1] + const ipv6Regex = /^(\[)?([0-9a-f:]+)(\])?$/u; + if (ipv6Regex.test(lowerHostname) && lowerHostname.includes(':')) { return true; } @@ -572,9 +556,8 @@ export async function initializeRpcProviderDomains(): Promise { if (chain.rpc && Array.isArray(chain.rpc)) { for (const rpcUrl of chain.rpc) { const hostname = extractHostname(rpcUrl); - // Filter out localhost and private IPs - these should never be - // considered "public" even if they appear in chainlist - if (hostname && !isLocalhostOrPrivateIP(hostname)) { + // Filter out localhost and IP addresses - public providers use domain names + if (hostname && !isLocalhostOrIPAddress(hostname)) { knownDomainsSet.add(hostname); } } From c9098e12d6f155eed007e9392c034f714799e123 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 29 Jan 2026 21:31:39 +0100 Subject: [PATCH 06/10] fix: wrap the analytics tracking in a try-catch block --- .../networks-form/networks-form.tsx | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx index b540b270c37a..0ace2ef1e921 100644 --- a/ui/pages/settings/networks-tab/networks-form/networks-form.tsx +++ b/ui/pages/settings/networks-tab/networks-form/networks-form.tsx @@ -314,45 +314,52 @@ export const NetworksForm = ({ } // Track RPC update from network connection banner + // Wrapped in try-catch to prevent analytics failures from affecting the UI + // since the network update has already succeeded at this point if (trackRpcUpdateFromBanner) { - const newRpcEndpoint = - networkPayload.rpcEndpoints[ - networkPayload.defaultRpcEndpointIndex - ]; - const oldRpcEndpoint = - existingNetwork.rpcEndpoints?.[ - existingNetwork.defaultRpcEndpointIndex ?? 0 - ]; - - const chainIdAsDecimal = hexToNumber(chainIdHex); - - const sanitizeRpcUrl = async (url: string) => { - const isPublic = await submitRequestToBackground( - 'isPublicEndpointUrl', - [url], - ); - return isPublic ? onlyKeepHost(url) : 'custom'; - }; - - const [fromRpcDomain, toRpcDomain] = await Promise.all([ - oldRpcEndpoint?.url - ? sanitizeRpcUrl(oldRpcEndpoint.url) - : Promise.resolve('unknown'), - sanitizeRpcUrl(newRpcEndpoint.url), - ]); - - trackEvent({ - category: MetaMetricsEventCategory.Network, - event: MetaMetricsEventName.NetworkConnectionBannerRpcUpdated, - // The names of Segment properties have a particular case. - /* eslint-disable @typescript-eslint/naming-convention */ - properties: { - chain_id_caip: `eip155:${chainIdAsDecimal}`, - from_rpc_domain: fromRpcDomain, - to_rpc_domain: toRpcDomain, - }, - /* eslint-enable @typescript-eslint/naming-convention */ - }); + try { + const newRpcEndpoint = + networkPayload.rpcEndpoints[ + networkPayload.defaultRpcEndpointIndex + ]; + const oldRpcEndpoint = + existingNetwork.rpcEndpoints?.[ + existingNetwork.defaultRpcEndpointIndex ?? 0 + ]; + + const chainIdAsDecimal = hexToNumber(chainIdHex); + + const sanitizeRpcUrl = async (url: string) => { + const isPublic = await submitRequestToBackground( + 'isPublicEndpointUrl', + [url], + ); + return isPublic ? onlyKeepHost(url) : 'custom'; + }; + + const [fromRpcDomain, toRpcDomain] = await Promise.all([ + oldRpcEndpoint?.url + ? sanitizeRpcUrl(oldRpcEndpoint.url) + : Promise.resolve('unknown'), + sanitizeRpcUrl(newRpcEndpoint.url), + ]); + + trackEvent({ + category: MetaMetricsEventCategory.Network, + event: MetaMetricsEventName.NetworkConnectionBannerRpcUpdated, + // The names of Segment properties have a particular case. + /* eslint-disable @typescript-eslint/naming-convention */ + properties: { + chain_id_caip: `eip155:${chainIdAsDecimal}`, + from_rpc_domain: fromRpcDomain, + to_rpc_domain: toRpcDomain, + }, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + } catch (error) { + // Analytics tracking failed, but network update succeeded - don't surface this error + console.error('Failed to track RPC update analytics:', error); + } } } else { await dispatch(addNetwork(networkPayload)); From c53ff184eba7be35811d2dc803891f70e53619f8 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 29 Jan 2026 22:02:49 +0100 Subject: [PATCH 07/10] fix: use ip-regex --- app/scripts/lib/util.ts | 14 +++++--------- package.json | 1 + yarn.lock | 3 ++- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/scripts/lib/util.ts b/app/scripts/lib/util.ts index b75742c4e740..3052ccf383ea 100644 --- a/app/scripts/lib/util.ts +++ b/app/scripts/lib/util.ts @@ -1,4 +1,5 @@ import urlLib from 'url'; +import ipRegex from 'ip-regex'; import { AccessList } from '@ethereumjs/tx'; import BN from 'bn.js'; import { memoize } from 'lodash'; @@ -518,16 +519,11 @@ function isLocalhostOrIPAddress(hostname: string): boolean { return true; } - // Check for IPv4 address (e.g., 192.168.1.1, 127.0.0.1, 8.8.8.8) - const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/u; - if (ipv4Regex.test(lowerHostname)) { - return true; - } + // Remove brackets from IPv6 addresses for testing (e.g., [::1] -> ::1) + const hostnameWithoutBrackets = lowerHostname.replace(/^\[|\]$/gu, ''); - // Check for IPv6 address (with or without brackets) - // Matches: ::1, [::1], 2001:db8::1, [2001:db8::1] - const ipv6Regex = /^(\[)?([0-9a-f:]+)(\])?$/u; - if (ipv6Regex.test(lowerHostname) && lowerHostname.includes(':')) { + // Check for IP address (v4 or v6) + if (ipRegex({ exact: true }).test(hostnameWithoutBrackets)) { return true; } diff --git a/package.json b/package.json index 3d2f96e61e35..579a0fb27222 100644 --- a/package.json +++ b/package.json @@ -447,6 +447,7 @@ "human-standard-token-abi": "^2.0.0", "humanize-duration": "^3.32.1", "immer": "^9.0.6", + "ip-regex": "^4.3.0", "is-retry-allowed": "^2.2.0", "jest-junit": "^14.0.1", "labeled-stream-splicer": "^2.0.2", diff --git a/yarn.lock b/yarn.lock index 254f13bfaefc..72bd340ed6e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29461,7 +29461,7 @@ __metadata: languageName: node linkType: hard -"ip-regex@npm:^4.0.0": +"ip-regex@npm:^4.0.0, ip-regex@npm:^4.3.0": version: 4.3.0 resolution: "ip-regex@npm:4.3.0" checksum: 10/7ff904b891221b1847f3fdf3dbb3e6a8660dc39bc283f79eb7ed88f5338e1a3d1104b779bc83759159be266249c59c2160e779ee39446d79d4ed0890dfd06f08 @@ -33927,6 +33927,7 @@ __metadata: husky: "npm:^8.0.3" immer: "npm:^9.0.6" ini: "npm:^3.0.0" + ip-regex: "npm:^4.3.0" is-retry-allowed: "npm:^2.2.0" jest: "npm:^29.7.0" jest-canvas-mock: "npm:^2.3.1" From c97f0fbf7da2d569bcccb219c10471d846813c98 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 30 Jan 2026 11:25:58 +0100 Subject: [PATCH 08/10] fix: make trackNetworkBannerEvent a safe promise --- ui/hooks/useNetworkConnectionBanner.ts | 85 ++++++++++++++------------ 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/ui/hooks/useNetworkConnectionBanner.ts b/ui/hooks/useNetworkConnectionBanner.ts index 555502948194..b91ef24a8d99 100644 --- a/ui/hooks/useNetworkConnectionBanner.ts +++ b/ui/hooks/useNetworkConnectionBanner.ts @@ -84,49 +84,54 @@ export const useNetworkConnectionBanner = eventName: string; networkClientId: string; }) => { - let foundNetwork: { chainId: Hex; url: string } | undefined; - for (const networkConfiguration of Object.values( - networkConfigurationsByChainId, - )) { - const rpcEndpoint = networkConfiguration.rpcEndpoints.find( - (endpoint) => endpoint.networkClientId === networkClientId, - ); - if (rpcEndpoint) { - foundNetwork = { - chainId: networkConfiguration.chainId, - url: rpcEndpoint.url, - }; - break; + try { + let foundNetwork: { chainId: Hex; url: string } | undefined; + for (const networkConfiguration of Object.values( + networkConfigurationsByChainId, + )) { + const rpcEndpoint = networkConfiguration.rpcEndpoints.find( + (endpoint) => endpoint.networkClientId === networkClientId, + ); + if (rpcEndpoint) { + foundNetwork = { + chainId: networkConfiguration.chainId, + url: rpcEndpoint.url, + }; + break; + } + } + if (!foundNetwork) { + console.warn( + `RPC endpoint not found for network client ID: ${networkClientId}`, + ); + return; } - } - if (!foundNetwork) { - console.warn( - `RPC endpoint not found for network client ID: ${networkClientId}`, - ); - return; - } - const rpcUrl = foundNetwork.url; - const chainIdAsDecimal = hexToNumber(foundNetwork.chainId); - const isPublic = await submitRequestToBackground( - 'isPublicEndpointUrl', - [rpcUrl], - ); - const sanitizedRpcUrl = isPublic ? onlyKeepHost(rpcUrl) : 'custom'; + const rpcUrl = foundNetwork.url; + const chainIdAsDecimal = hexToNumber(foundNetwork.chainId); + const isPublic = await submitRequestToBackground( + 'isPublicEndpointUrl', + [rpcUrl], + ); + const sanitizedRpcUrl = isPublic ? onlyKeepHost(rpcUrl) : 'custom'; - trackEvent({ - category: MetaMetricsEventCategory.Network, - event: eventName, - // The names of Segment properties have a particular case. - /* eslint-disable @typescript-eslint/naming-convention */ - properties: { - banner_type: bannerType, - chain_id_caip: `eip155:${chainIdAsDecimal}`, - rpc_domain: sanitizedRpcUrl, - rpc_endpoint_url: sanitizedRpcUrl, // @deprecated - Will be removed in a future release. - }, - /* eslint-enable @typescript-eslint/naming-convention */ - }); + trackEvent({ + category: MetaMetricsEventCategory.Network, + event: eventName, + // The names of Segment properties have a particular case. + /* eslint-disable @typescript-eslint/naming-convention */ + properties: { + banner_type: bannerType, + chain_id_caip: `eip155:${chainIdAsDecimal}`, + rpc_domain: sanitizedRpcUrl, + rpc_endpoint_url: sanitizedRpcUrl, // @deprecated - Will be removed in a future release. + }, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + } catch (error) { + // Analytics tracking failed - don't surface this error since it's non-critical + console.error('Failed to track network banner event:', error); + } }, [networkConfigurationsByChainId, trackEvent], ); From 81ebc84fbf0c80b2ac80e9d5c3328c5d5641fd0c Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 30 Jan 2026 17:36:43 +0100 Subject: [PATCH 09/10] feat: filter RFC 6761 special-use domains from known RPC providers Add isSpecialUseDomain() to filter out reserved domains per RFC 6761: - Special-use TLDs: .test, .localhost, .invalid, .example, .local - Reserved example domains: example.com, example.net, example.org These domains should never appear as legitimate public RPC providers. --- app/scripts/lib/util.test.js | 84 ++++++++++++++++++++++++++++++++++++ app/scripts/lib/util.ts | 50 ++++++++++++++++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/app/scripts/lib/util.test.js b/app/scripts/lib/util.test.js index b59de86fe965..9937184f2b68 100644 --- a/app/scripts/lib/util.test.js +++ b/app/scripts/lib/util.test.js @@ -31,6 +31,7 @@ import { isKnownDomain, initializeRpcProviderDomains, isPublicEndpointUrl, + isSpecialUseDomain, } from './util'; // Mock the module @@ -672,4 +673,87 @@ describe('app utils', () => { }); }); }); + + describe('isSpecialUseDomain', () => { + describe('RFC 6761 special-use TLDs', () => { + it('should return true for .test TLD', () => { + expect(isSpecialUseDomain('myapp.test')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.test')).toBe(true); + }); + + it('should return true for .localhost TLD', () => { + expect(isSpecialUseDomain('myapp.localhost')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.localhost')).toBe(true); + }); + + it('should return true for .invalid TLD', () => { + expect(isSpecialUseDomain('myapp.invalid')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.invalid')).toBe(true); + }); + + it('should return true for .example TLD', () => { + expect(isSpecialUseDomain('myapp.example')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.example')).toBe(true); + }); + + it('should return true for .local TLD', () => { + expect(isSpecialUseDomain('myapp.local')).toBe(true); + expect(isSpecialUseDomain('rpc.myapp.local')).toBe(true); + }); + }); + + describe('RFC 6761 reserved example domains', () => { + it('should return true for example.com', () => { + expect(isSpecialUseDomain('example.com')).toBe(true); + expect(isSpecialUseDomain('rpc.example.com')).toBe(true); + expect(isSpecialUseDomain('api.rpc.example.com')).toBe(true); + }); + + it('should return true for example.net', () => { + expect(isSpecialUseDomain('example.net')).toBe(true); + expect(isSpecialUseDomain('rpc.example.net')).toBe(true); + expect(isSpecialUseDomain('api.rpc.example.net')).toBe(true); + }); + + it('should return true for example.org', () => { + expect(isSpecialUseDomain('example.org')).toBe(true); + expect(isSpecialUseDomain('rpc.example.org')).toBe(true); + expect(isSpecialUseDomain('api.rpc.example.org')).toBe(true); + }); + }); + + describe('case insensitivity', () => { + it('should be case insensitive', () => { + expect(isSpecialUseDomain('EXAMPLE.COM')).toBe(true); + expect(isSpecialUseDomain('MyApp.TEST')).toBe(true); + expect(isSpecialUseDomain('RPC.Example.Org')).toBe(true); + }); + }); + + describe('valid public domains', () => { + it('should return false for regular domains', () => { + expect(isSpecialUseDomain('infura.io')).toBe(false); + expect(isSpecialUseDomain('mainnet.infura.io')).toBe(false); + expect(isSpecialUseDomain('alchemy.com')).toBe(false); + expect(isSpecialUseDomain('rpc.ankr.com')).toBe(false); + }); + + it('should return false for domains containing but not ending with special TLDs', () => { + expect(isSpecialUseDomain('test-rpc.com')).toBe(false); + expect(isSpecialUseDomain('example-provider.io')).toBe(false); + expect(isSpecialUseDomain('localhost-rpc.net')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return false for empty string', () => { + expect(isSpecialUseDomain('')).toBe(false); + }); + + it('should return false for null/undefined', () => { + expect(isSpecialUseDomain(null)).toBe(false); + expect(isSpecialUseDomain(undefined)).toBe(false); + }); + }); + }); }); diff --git a/app/scripts/lib/util.ts b/app/scripts/lib/util.ts index 3052ccf383ea..38d898582f50 100644 --- a/app/scripts/lib/util.ts +++ b/app/scripts/lib/util.ts @@ -530,6 +530,47 @@ function isLocalhostOrIPAddress(hostname: string): boolean { return false; } +/** + * Check if a hostname is a special-use domain per RFC 6761. + * These domains are reserved and should never be used by real public RPC providers. + * + * @param hostname - The hostname to check. + * @returns True if the hostname is a special-use domain. + * @see https://datatracker.ietf.org/doc/html/rfc6761 + */ +export function isSpecialUseDomain(hostname: string): boolean { + if (!hostname) { + return false; + } + + const lowerHostname = hostname.toLowerCase(); + + // RFC 6761 special-use TLDs + if ( + lowerHostname.endsWith('.test') || + lowerHostname.endsWith('.localhost') || + lowerHostname.endsWith('.invalid') || + lowerHostname.endsWith('.example') || + lowerHostname.endsWith('.local') + ) { + return true; + } + + // RFC 6761 reserved example domains + if ( + lowerHostname === 'example.com' || + lowerHostname === 'example.net' || + lowerHostname === 'example.org' || + lowerHostname.endsWith('.example.com') || + lowerHostname.endsWith('.example.net') || + lowerHostname.endsWith('.example.org') + ) { + return true; + } + + return false; +} + /** * Initialize the set of known domains from the safe chainlist cache. * This should be called once at startup in the background context. @@ -552,8 +593,13 @@ export async function initializeRpcProviderDomains(): Promise { if (chain.rpc && Array.isArray(chain.rpc)) { for (const rpcUrl of chain.rpc) { const hostname = extractHostname(rpcUrl); - // Filter out localhost and IP addresses - public providers use domain names - if (hostname && !isLocalhostOrIPAddress(hostname)) { + // Filter out localhost, IP addresses, and RFC 6761 special-use domains + // Public providers use real domain names + if ( + hostname && + !isLocalhostOrIPAddress(hostname) && + !isSpecialUseDomain(hostname) + ) { knownDomainsSet.add(hostname); } } From f5769e08922d77ead04a4cf5d8d17546d9f5c123 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Fri, 30 Jan 2026 18:22:17 +0100 Subject: [PATCH 10/10] refactor: improve isSpecialUseDomain implementation - Remove redundant hostname null checks (type guarantees string) - Extract TLDs and domains into const arrays for maintainability - Use .some() for cleaner iteration --- app/scripts/lib/util.test.js | 5 ---- app/scripts/lib/util.ts | 53 ++++++++++++++---------------------- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/app/scripts/lib/util.test.js b/app/scripts/lib/util.test.js index 9937184f2b68..846f75817161 100644 --- a/app/scripts/lib/util.test.js +++ b/app/scripts/lib/util.test.js @@ -749,11 +749,6 @@ describe('app utils', () => { it('should return false for empty string', () => { expect(isSpecialUseDomain('')).toBe(false); }); - - it('should return false for null/undefined', () => { - expect(isSpecialUseDomain(null)).toBe(false); - expect(isSpecialUseDomain(undefined)).toBe(false); - }); }); }); }); diff --git a/app/scripts/lib/util.ts b/app/scripts/lib/util.ts index 38d898582f50..42a83bca2059 100644 --- a/app/scripts/lib/util.ts +++ b/app/scripts/lib/util.ts @@ -508,10 +508,6 @@ function extractHostname(url: string): string | null { * @returns True if the hostname is localhost or an IP address (v4 or v6). */ function isLocalhostOrIPAddress(hostname: string): boolean { - if (!hostname) { - return false; - } - const lowerHostname = hostname.toLowerCase(); // Check for localhost @@ -530,6 +526,18 @@ function isLocalhostOrIPAddress(hostname: string): boolean { return false; } +// RFC 6761 special-use TLDs that should never be used by real public RPC providers +const SPECIAL_USE_TLDS = [ + '.test', + '.localhost', + '.invalid', + '.example', + '.local', +] as const; + +// RFC 6761 reserved example domains +const RESERVED_EXAMPLE_DOMAINS = ['example.com', 'example.net', 'example.org']; + /** * Check if a hostname is a special-use domain per RFC 6761. * These domains are reserved and should never be used by real public RPC providers. @@ -539,31 +547,19 @@ function isLocalhostOrIPAddress(hostname: string): boolean { * @see https://datatracker.ietf.org/doc/html/rfc6761 */ export function isSpecialUseDomain(hostname: string): boolean { - if (!hostname) { - return false; - } - const lowerHostname = hostname.toLowerCase(); - // RFC 6761 special-use TLDs - if ( - lowerHostname.endsWith('.test') || - lowerHostname.endsWith('.localhost') || - lowerHostname.endsWith('.invalid') || - lowerHostname.endsWith('.example') || - lowerHostname.endsWith('.local') - ) { + // Check special-use TLDs + if (SPECIAL_USE_TLDS.some((tld) => lowerHostname.endsWith(tld))) { return true; } - // RFC 6761 reserved example domains + // Check reserved example domains (exact match or subdomain) if ( - lowerHostname === 'example.com' || - lowerHostname === 'example.net' || - lowerHostname === 'example.org' || - lowerHostname.endsWith('.example.com') || - lowerHostname.endsWith('.example.net') || - lowerHostname.endsWith('.example.org') + RESERVED_EXAMPLE_DOMAINS.some( + (domain) => + lowerHostname === domain || lowerHostname.endsWith(`.${domain}`), + ) ) { return true; } @@ -615,16 +611,13 @@ export async function initializeRpcProviderDomains(): Promise { } /** - * Check if a domain is in the known domains list. + * Check if a domain is in the known domains list * * @param domain - The domain to check. * @returns True if the domain is found in the chainlist cache. */ export function isKnownDomain(domain: string): boolean { - if (!domain) { - return false; - } - return knownDomainsSet?.has(domain.toLowerCase()) ?? false; + return knownDomainsSet?.has(domain?.toLowerCase()) ?? false; } /** @@ -648,10 +641,6 @@ function isKnownEndpointUrl(endpointUrl: string): boolean { * want to report these in Segment (or any other data collection service). This * function returns whether the given RPC endpoint is safe to share. * - * This function is only available in the background context where the set of - * known domains is initialized. UI should call the background API method - * instead. - * * @param endpointUrl - The URL of the endpoint. * @param infuraProjectId - Our Infura project ID. * @returns True if the endpoint URL is safe to share with external data