@metamask-previews/assets-controllers 65.0.0-preview-88a682b → 65.0.0-preview-4f323233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - Add phishing protection for NFT metadata URLs in `NftController` ([#5598](https://github.com/MetaMask/core/pull/5598))
13
+ - NFT metadata URLs are now scanned for malicious content using the `PhishingController`
14
+ - Malicious URLs in NFT metadata fields (image, externalLink, etc.) are automatically sanitized
15
+
16
+ ### Changed
17
+
18
+ - **BREAKING:** Add peer dependency on `@metamask/phishing-controller` ^12.5.0 ([#5598](https://github.com/MetaMask/core/pull/5598))
19
+
10
20
  ## [65.0.0]
11
21
 
12
22
  ### Added
@@ -62,8 +62,8 @@ type AccountTrackerPollingInput = {
62
62
  networkClientIds: NetworkClientId[];
63
63
  };
64
64
  declare const AccountTrackerController_base: (abstract new (...args: any[]) => {
65
- readonly "__#13@#intervalIds": Record<string, NodeJS.Timeout>;
66
- "__#13@#intervalLength": number | undefined;
65
+ readonly "__#14@#intervalIds": Record<string, NodeJS.Timeout>;
66
+ "__#14@#intervalLength": number | undefined;
67
67
  setIntervalLength(intervalLength: number): void;
68
68
  getIntervalLength(): number | undefined;
69
69
  _startPolling(input: AccountTrackerPollingInput): void;
@@ -62,8 +62,8 @@ type AccountTrackerPollingInput = {
62
62
  networkClientIds: NetworkClientId[];
63
63
  };
64
64
  declare const AccountTrackerController_base: (abstract new (...args: any[]) => {
65
- readonly "__#13@#intervalIds": Record<string, NodeJS.Timeout>;
66
- "__#13@#intervalLength": number | undefined;
65
+ readonly "__#14@#intervalIds": Record<string, NodeJS.Timeout>;
66
+ "__#14@#intervalLength": number | undefined;
67
67
  setIntervalLength(intervalLength: number): void;
68
68
  getIntervalLength(): number | undefined;
69
69
  _startPolling(input: AccountTrackerPollingInput): void;
@@ -29,8 +29,8 @@ type CurrencyRatePollingInput = {
29
29
  nativeCurrencies: string[];
30
30
  };
31
31
  declare const CurrencyRateController_base: (abstract new (...args: any[]) => {
32
- readonly "__#13@#intervalIds": Record<string, NodeJS.Timeout>;
33
- "__#13@#intervalLength": number | undefined;
32
+ readonly "__#14@#intervalIds": Record<string, NodeJS.Timeout>;
33
+ "__#14@#intervalLength": number | undefined;
34
34
  setIntervalLength(intervalLength: number): void;
35
35
  getIntervalLength(): number | undefined;
36
36
  _startPolling(input: CurrencyRatePollingInput): void;
@@ -29,8 +29,8 @@ type CurrencyRatePollingInput = {
29
29
  nativeCurrencies: string[];
30
30
  };
31
31
  declare const CurrencyRateController_base: (abstract new (...args: any[]) => {
32
- readonly "__#13@#intervalIds": Record<string, NodeJS.Timeout>;
33
- "__#13@#intervalLength": number | undefined;
32
+ readonly "__#14@#intervalIds": Record<string, NodeJS.Timeout>;
33
+ "__#14@#intervalLength": number | undefined;
34
34
  setIntervalLength(intervalLength: number): void;
35
35
  getIntervalLength(): number | undefined;
36
36
  _startPolling(input: CurrencyRatePollingInput): void;
@@ -36,8 +36,8 @@ export type AllowedEvents = KeyringControllerUnlockEvent | KeyringControllerLock
36
36
  */
37
37
  export type DeFiPositionsControllerMessenger = RestrictedMessenger<typeof controllerName, DeFiPositionsControllerActions | AllowedActions, DeFiPositionsControllerEvents | AllowedEvents, AllowedActions['type'], AllowedEvents['type']>;
38
38
  declare const DeFiPositionsController_base: (abstract new (...args: any[]) => {
39
- readonly "__#13@#intervalIds": Record<string, NodeJS.Timeout>;
40
- "__#13@#intervalLength": number | undefined;
39
+ readonly "__#14@#intervalIds": Record<string, NodeJS.Timeout>;
40
+ "__#14@#intervalLength": number | undefined;
41
41
  setIntervalLength(intervalLength: number): void;
42
42
  getIntervalLength(): number | undefined;
43
43
  _startPolling(input: import("@metamask/utils").Json): void;
@@ -36,8 +36,8 @@ export type AllowedEvents = KeyringControllerUnlockEvent | KeyringControllerLock
36
36
  */
37
37
  export type DeFiPositionsControllerMessenger = RestrictedMessenger<typeof controllerName, DeFiPositionsControllerActions | AllowedActions, DeFiPositionsControllerEvents | AllowedEvents, AllowedActions['type'], AllowedEvents['type']>;
38
38
  declare const DeFiPositionsController_base: (abstract new (...args: any[]) => {
39
- readonly "__#13@#intervalIds": Record<string, NodeJS.Timeout>;
40
- "__#13@#intervalLength": number | undefined;
39
+ readonly "__#14@#intervalIds": Record<string, NodeJS.Timeout>;
40
+ "__#14@#intervalLength": number | undefined;
41
41
  setIntervalLength(intervalLength: number): void;
42
42
  getIntervalLength(): number | undefined;
43
43
  _startPolling(input: import("@metamask/utils").Json): void;
@@ -74,8 +74,8 @@ export type MultichainAssetsRatesPollingInput = {
74
74
  accountId: string;
75
75
  };
76
76
  declare const MultichainAssetsRatesController_base: (abstract new (...args: any[]) => {
77
- readonly "__#13@#intervalIds": Record<string, NodeJS.Timeout>;
78
- "__#13@#intervalLength": number | undefined;
77
+ readonly "__#14@#intervalIds": Record<string, NodeJS.Timeout>;
78
+ "__#14@#intervalLength": number | undefined;
79
79
  setIntervalLength(intervalLength: number): void;
80
80
  getIntervalLength(): number | undefined;
81
81
  _startPolling(input: MultichainAssetsRatesPollingInput): void;
@@ -74,8 +74,8 @@ export type MultichainAssetsRatesPollingInput = {
74
74
  accountId: string;
75
75
  };
76
76
  declare const MultichainAssetsRatesController_base: (abstract new (...args: any[]) => {
77
- readonly "__#13@#intervalIds": Record<string, NodeJS.Timeout>;
78
- "__#13@#intervalLength": number | undefined;
77
+ readonly "__#14@#intervalIds": Record<string, NodeJS.Timeout>;
78
+ "__#14@#intervalLength": number | undefined;
79
79
  setIntervalLength(intervalLength: number): void;
80
80
  getIntervalLength(): number | undefined;
81
81
  _startPolling(input: MultichainAssetsRatesPollingInput): void;
@@ -13,12 +13,13 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
13
13
  var __importDefault = (this && this.__importDefault) || function (mod) {
14
14
  return (mod && mod.__esModule) ? mod : { "default": mod };
15
15
  };
16
- var _NftController_instances, _NftController_mutex, _NftController_selectedAccountId, _NftController_chainId, _NftController_ipfsGateway, _NftController_openSeaEnabled, _NftController_useIpfsSubdomains, _NftController_isIpfsGatewayEnabled, _NftController_onNftAdded, _NftController_onNetworkControllerNetworkDidChange, _NftController_onPreferencesControllerStateChange, _NftController_onSelectedAccountChange, _NftController_updateNestedNftState, _NftController_getNftCollectionApi, _NftController_getNftInformationFromApi, _NftController_getNftInformationFromTokenURI, _NftController_getNftURIAndStandard, _NftController_getNftInformation, _NftController_getNftContractInformationFromContract, _NftController_getNftContractInformation, _NftController_addIndividualNft, _NftController_addNftContract, _NftController_removeAndIgnoreIndividualNft, _NftController_removeIndividualNft, _NftController_removeNftContract, _NftController_validateWatchNft, _NftController_getCorrectChainId, _NftController_getAddressOrSelectedAddress, _NftController_updateNftUpdateForAccount;
16
+ var _NftController_instances, _NftController_mutex, _NftController_selectedAccountId, _NftController_chainId, _NftController_ipfsGateway, _NftController_openSeaEnabled, _NftController_useIpfsSubdomains, _NftController_isIpfsGatewayEnabled, _NftController_onNftAdded, _NftController_onNetworkControllerNetworkDidChange, _NftController_onPreferencesControllerStateChange, _NftController_onSelectedAccountChange, _NftController_updateNestedNftState, _NftController_getNftCollectionApi, _NftController_getNftInformationFromApi, _NftController_getNftInformationFromTokenURI, _NftController_getNftURIAndStandard, _NftController_getNftInformation, _NftController_getNftContractInformationFromContract, _NftController_getNftContractInformation, _NftController_addIndividualNft, _NftController_addNftContract, _NftController_removeAndIgnoreIndividualNft, _NftController_removeIndividualNft, _NftController_removeNftContract, _NftController_validateWatchNft, _NftController_getCorrectChainId, _NftController_getAddressOrSelectedAddress, _NftController_updateNftUpdateForAccount, _NftController_bulkSanitizeNftMetadata, _NftController_sanitizeNftMetadata;
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.NftController = exports.getDefaultNftControllerState = void 0;
19
19
  const address_1 = require("@ethersproject/address");
20
20
  const base_controller_1 = require("@metamask/base-controller");
21
21
  const controller_utils_1 = require("@metamask/controller-utils");
22
+ const phishing_controller_1 = require("@metamask/phishing-controller");
22
23
  const rpc_errors_1 = require("@metamask/rpc-errors");
23
24
  const utils_1 = require("@metamask/utils");
24
25
  const async_mutex_1 = require("async-mutex");
@@ -124,11 +125,13 @@ class NftController extends base_controller_1.BaseController {
124
125
  }
125
126
  await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_validateWatchNft).call(this, asset, type, addressToSearch);
126
127
  const nftMetadata = await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_getNftInformation).call(this, asset.address, asset.tokenId, networkClientId);
127
- if (nftMetadata.standard && nftMetadata.standard !== type) {
128
- throw rpc_errors_1.rpcErrors.invalidInput(`Suggested NFT of type ${nftMetadata.standard} does not match received type ${type}`);
128
+ // Sanitize metadata
129
+ const sanitizedMetadata = await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_sanitizeNftMetadata).call(this, nftMetadata);
130
+ if (sanitizedMetadata.standard && sanitizedMetadata.standard !== type) {
131
+ throw rpc_errors_1.rpcErrors.invalidInput(`Suggested NFT of type ${sanitizedMetadata.standard} does not match received type ${type}`);
129
132
  }
130
133
  const suggestedNftMeta = {
131
- asset: { ...asset, ...nftMetadata },
134
+ asset: { ...asset, ...sanitizedMetadata },
132
135
  type,
133
136
  id: (0, uuid_1.v4)(),
134
137
  time: Date.now(),
@@ -137,7 +140,7 @@ class NftController extends base_controller_1.BaseController {
137
140
  };
138
141
  await this._requestApproval(suggestedNftMeta);
139
142
  const { address, tokenId } = asset;
140
- const { name, standard, description, image } = nftMetadata;
143
+ const { name, standard, description, image } = sanitizedMetadata;
141
144
  await this.addNft(address, tokenId, {
142
145
  nftMetadata: {
143
146
  name: name ?? null,
@@ -234,9 +237,15 @@ class NftController extends base_controller_1.BaseController {
234
237
  const checksumHexAddress = (0, controller_utils_1.toChecksumHexAddress)(tokenAddress);
235
238
  // TODO: revisit this with Solana support and instead of passing chainId, make sure chainId is read from nftMetadata
236
239
  const chainIdToAddTo = chainId || __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_getCorrectChainId).call(this, { networkClientId });
237
- nftMetadata =
238
- nftMetadata ||
239
- (await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_getNftInformation).call(this, checksumHexAddress, tokenId, networkClientId));
240
+ if (!nftMetadata) {
241
+ const fetchedMetadata = await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_getNftInformation).call(this, checksumHexAddress, tokenId, networkClientId);
242
+ // Sanitize metadata
243
+ nftMetadata = await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_sanitizeNftMetadata).call(this, fetchedMetadata);
244
+ }
245
+ else {
246
+ // Sanitize provided metadata
247
+ nftMetadata = await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_sanitizeNftMetadata).call(this, nftMetadata);
248
+ }
240
249
  const newNftContracts = await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_addNftContract).call(this, {
241
250
  tokenAddress: checksumHexAddress,
242
251
  userAddress: addressToSearch,
@@ -275,13 +284,23 @@ class NftController extends base_controller_1.BaseController {
275
284
  address: (0, controller_utils_1.toChecksumHexAddress)(nft.address),
276
285
  };
277
286
  });
278
- const nftMetadataResults = await Promise.all(nftsWithChecksumAdr.map(async (nft) => {
287
+ // Get all unsanitized nft metadata
288
+ const unsanitizedResults = await Promise.all(nftsWithChecksumAdr.map(async (nft) => {
279
289
  const resMetadata = await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_getNftInformation).call(this, nft.address, nft.tokenId, networkClientId);
280
290
  return {
281
291
  nft,
282
292
  newMetadata: resMetadata,
283
293
  };
284
294
  }));
295
+ // Extract metadata
296
+ const unsanitizedMetadata = unsanitizedResults.map((result) => result.newMetadata);
297
+ // Sanitize all metadata
298
+ const sanitizedMetadata = await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_bulkSanitizeNftMetadata).call(this, unsanitizedMetadata);
299
+ // Reassemble the results with sanitized metadata
300
+ const nftMetadataResults = unsanitizedResults.map((result, index) => ({
301
+ nft: result.nft,
302
+ newMetadata: sanitizedMetadata[index],
303
+ }));
285
304
  // We want to avoid updating the state if the state and fetched nft info are the same
286
305
  const nftsWithDifferentMetadata = [];
287
306
  const { allNfts } = this.state;
@@ -857,7 +876,7 @@ async function _NftController_getNftInformation(contractAddress, tokenId, networ
857
876
  ? (0, controller_utils_1.safelyExecute)(() => __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_getNftInformationFromApi).call(this, contractAddress, tokenId))
858
877
  : undefined,
859
878
  ]);
860
- return {
879
+ const metadata = {
861
880
  ...nftApiMetadata,
862
881
  name: blockchainMetadata?.name ?? nftApiMetadata?.name ?? null,
863
882
  description: blockchainMetadata?.description ?? nftApiMetadata?.description ?? null,
@@ -865,6 +884,8 @@ async function _NftController_getNftInformation(contractAddress, tokenId, networ
865
884
  standard: blockchainMetadata?.standard ?? nftApiMetadata?.standard ?? null,
866
885
  tokenURI: blockchainMetadata?.tokenURI ?? null,
867
886
  };
887
+ // Sanitize the metadata by checking external links against phishing protection
888
+ return await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_sanitizeNftMetadata).call(this, metadata);
868
889
  }, _NftController_getNftContractInformationFromContract =
869
890
  /**
870
891
  * Request NFT contract information from the contract itself.
@@ -1176,6 +1197,101 @@ async function _NftController_addNftContract({ tokenAddress, userAddress, networ
1176
1197
  userAddress: account.address,
1177
1198
  });
1178
1199
  }
1200
+ }, _NftController_bulkSanitizeNftMetadata =
1201
+ /**
1202
+ * Sanitizes multiple NFT metadata objects by checking external links against PhishingController in a single bulk request
1203
+ *
1204
+ * @param metadataList - Array of NFT metadata objects to sanitize
1205
+ * @returns Array of sanitized NFT metadata objects
1206
+ */
1207
+ async function _NftController_bulkSanitizeNftMetadata(metadataList) {
1208
+ // Create a copy of the metadata list to avoid mutating the input
1209
+ const sanitizedMetadataList = metadataList.map((metadata) => ({
1210
+ ...metadata,
1211
+ }));
1212
+ // Maps URL to a list of {metadataIndex, fieldName} to track where each URL is used
1213
+ const urlMap = {};
1214
+ const fieldsToCheck = [
1215
+ 'externalLink',
1216
+ 'image',
1217
+ 'imagePreview',
1218
+ 'imageThumbnail',
1219
+ 'imageOriginal',
1220
+ 'animation',
1221
+ 'animationOriginal',
1222
+ ];
1223
+ // Collect all URLs from all metadata objects
1224
+ sanitizedMetadataList.forEach((metadata, metadataIndex) => {
1225
+ // Check regular fields
1226
+ for (const field of fieldsToCheck) {
1227
+ const url = metadata[field];
1228
+ if (typeof url === 'string' && url && url.startsWith('http')) {
1229
+ if (!urlMap[url]) {
1230
+ urlMap[url] = [];
1231
+ }
1232
+ urlMap[url].push({ metadataIndex, fieldName: field });
1233
+ }
1234
+ }
1235
+ // Check collection links if they exist
1236
+ if (metadata.collection) {
1237
+ const { collection } = metadata;
1238
+ if ('externalLink' in collection &&
1239
+ typeof collection.externalLink === 'string') {
1240
+ const url = collection.externalLink;
1241
+ if (!urlMap[url]) {
1242
+ urlMap[url] = [];
1243
+ }
1244
+ urlMap[url].push({
1245
+ metadataIndex,
1246
+ fieldName: 'collection.externalLink',
1247
+ });
1248
+ }
1249
+ }
1250
+ });
1251
+ const urlsToCheck = Object.keys(urlMap);
1252
+ if (urlsToCheck.length === 0) {
1253
+ return sanitizedMetadataList;
1254
+ }
1255
+ try {
1256
+ // Use bulkScanUrls to check all URLs at once
1257
+ const bulkScanResponse = await this.messagingSystem.call('PhishingController:bulkScanUrls', urlsToCheck);
1258
+ // Apply scan results to all metadata objects
1259
+ Object.entries(bulkScanResponse.results).forEach(([url, result]) => {
1260
+ if (result.recommendedAction === phishing_controller_1.RecommendedAction.Block) {
1261
+ // Remove this URL from all metadata objects where it appears
1262
+ urlMap[url].forEach(({ metadataIndex, fieldName }) => {
1263
+ if (fieldName === 'collection.externalLink' &&
1264
+ sanitizedMetadataList[metadataIndex].collection // Check if collection exists
1265
+ ) {
1266
+ const { collection } = sanitizedMetadataList[metadataIndex];
1267
+ // Ensure collection is not undefined again just to be safe before using 'in'
1268
+ if (collection && 'externalLink' in collection) {
1269
+ delete collection.externalLink;
1270
+ }
1271
+ }
1272
+ else {
1273
+ delete sanitizedMetadataList[metadataIndex][fieldName];
1274
+ }
1275
+ });
1276
+ }
1277
+ });
1278
+ }
1279
+ catch (error) {
1280
+ console.error('Error during bulk URL scanning:', error);
1281
+ // If bulk scan fails, we fall back to keeping all URLs
1282
+ }
1283
+ return sanitizedMetadataList;
1284
+ }, _NftController_sanitizeNftMetadata =
1285
+ /**
1286
+ * Sanitizes NFT metadata by checking external links against PhishingController
1287
+ *
1288
+ * @param metadata - The NFT metadata to sanitize
1289
+ * @returns Sanitized NFT metadata with potentially dangerous links removed
1290
+ */
1291
+ async function _NftController_sanitizeNftMetadata(metadata) {
1292
+ // Use the bulk sanitize function with just a single metadata object
1293
+ const sanitized = await __classPrivateFieldGet(this, _NftController_instances, "m", _NftController_bulkSanitizeNftMetadata).call(this, [metadata]);
1294
+ return sanitized[0];
1179
1295
  };
1180
1296
  exports.default = NftController;
1181
1297
  //# sourceMappingURL=NftController.cjs.map