diff --git a/app/components/Views/confirmations/components/info/personal-sign/message.tsx b/app/components/Views/confirmations/components/info/personal-sign/message.tsx index 82223fa2bfb1..a61d6223cced 100644 --- a/app/components/Views/confirmations/components/info/personal-sign/message.tsx +++ b/app/components/Views/confirmations/components/info/personal-sign/message.tsx @@ -8,7 +8,7 @@ import Text from '../../../../../../component-library/components/Texts/Text'; import { Theme } from '../../../../../../util/theme/models'; import { fontStyles } from '../../../../../../styles/common'; import { useStyles } from '../../../../../../component-library/hooks'; -import { sanitizeString } from '../../../../../../util/string'; +import { escapeSpecialUnicode } from '../../../../../../util/string'; import { getSIWEDetails, SIWEMessage } from '../../../utils/signature'; import { useSignatureRequest } from '../../../hooks/signatures/useSignatureRequest'; import Address from '../../UI/info-row/info-value/address'; @@ -104,7 +104,7 @@ const Message = () => { if (!signatureRequest?.messageParams?.data) { return ''; } - return sanitizeString( + return escapeSpecialUnicode( hexToText(signatureRequest?.messageParams?.data as string), ); }, [signatureRequest?.messageParams?.data]); diff --git a/app/components/Views/confirmations/components/info/typed-sign-v3v4/info-section-origin-and-details/info-section-origin-and-details.tsx b/app/components/Views/confirmations/components/info/typed-sign-v3v4/info-section-origin-and-details/info-section-origin-and-details.tsx index c8991c4a52e7..b04e1d4ea828 100644 --- a/app/components/Views/confirmations/components/info/typed-sign-v3v4/info-section-origin-and-details/info-section-origin-and-details.tsx +++ b/app/components/Views/confirmations/components/info/typed-sign-v3v4/info-section-origin-and-details/info-section-origin-and-details.tsx @@ -8,7 +8,10 @@ import { InfoRowDivider } from '../../../UI/info-row/divider'; import InfoSection from '../../../UI/info-row/info-section'; import InfoRowAddress from '../../../UI/info-row/info-value/address'; import DisplayURL from '../../../UI/info-row/info-value/display-url'; -import { isRecognizedPermit, parseTypedDataMessageFromSignatureRequest } from '../../../../utils/signature'; +import { + isRecognizedPermit, + parseAndNormalizeSignTypedDataFromSignatureRequest, +} from '../../../../utils/signature'; import { useSignatureRequest } from '../../../../hooks/signatures/useSignatureRequest'; import useApprovalRequest from '../../../../hooks/useApprovalRequest'; import { View } from 'react-native'; @@ -26,9 +29,9 @@ export const InfoSectionOriginAndDetails = () => { const signatureRequest = useSignatureRequest(); const isPermit = isRecognizedPermit(signatureRequest); - const parsedMessage = parseTypedDataMessageFromSignatureRequest(signatureRequest); - const spender = parsedMessage?.message?.spender; - const verifyingContract = parsedMessage?.domain?.verifyingContract; + const parsedData = parseAndNormalizeSignTypedDataFromSignatureRequest(signatureRequest); + const spender = parsedData.message?.spender; + const verifyingContract = parsedData.domain?.verifyingContract; if (!signatureRequest) { return null; @@ -57,13 +60,10 @@ export const InfoSectionOriginAndDetails = () => { {isValidAddress(verifyingContract) && ( - - - - )} + + + + )} ); }; diff --git a/app/components/Views/confirmations/components/info/typed-sign-v3v4/message.tsx b/app/components/Views/confirmations/components/info/typed-sign-v3v4/message.tsx index 3ee07024b6ad..8eb1ae61bea6 100644 --- a/app/components/Views/confirmations/components/info/typed-sign-v3v4/message.tsx +++ b/app/components/Views/confirmations/components/info/typed-sign-v3v4/message.tsx @@ -7,7 +7,7 @@ import { useSignatureRequest } from '../../../hooks/signatures/useSignatureReque import Text from '../../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../../component-library/hooks'; import { useTypedSignSimulationEnabled } from '../../../hooks/signatures/useTypedSignSimulationEnabled'; -import { parseSanitizeTypedDataMessage } from '../../../utils/signature'; +import { parseNormalizeAndSanitizeSignTypedData } from '../../../utils/signature'; import InfoRow from '../../UI/info-row'; import { useTokenDecimalsInTypedSignRequest } from '../../../hooks/signatures/useTokenDecimalsInTypedSignRequest'; import DataTree from '../../data-tree'; @@ -51,7 +51,7 @@ const Message = () => { sanitizedMessage, primaryType, } = useMemo( - () => parseSanitizeTypedDataMessage(typedSignData), + () => parseNormalizeAndSanitizeSignTypedData(typedSignData), [typedSignData], ); diff --git a/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/typed-sign-permit/typed-sign-permit.tsx b/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/typed-sign-permit/typed-sign-permit.tsx index c632b09f8985..d29cbba270af 100644 --- a/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/typed-sign-permit/typed-sign-permit.tsx +++ b/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/typed-sign-permit/typed-sign-permit.tsx @@ -8,7 +8,7 @@ import Engine from '../../../../../../../../core/Engine'; import { safeToChecksumAddress } from '../../../../../../../../util/address'; import { PrimaryType } from '../../../../../constants/signatures'; import { useSignatureRequest } from '../../../../../hooks/signatures/useSignatureRequest'; -import { isPermitDaiRevoke, parseTypedDataMessage } from '../../../../../utils/signature'; +import { isPermitDaiRevoke, parseAndNormalizeSignTypedData } from '../../../../../utils/signature'; import InfoRow from '../../../../UI/info-row'; import InfoSection from '../../../../UI/info-row/info-section'; import PermitSimulationValueDisplay from '../components/value-display'; @@ -74,7 +74,7 @@ const PermitSimulation = () => { message, message: { allowed, tokenId, value }, primaryType, - } = parseTypedDataMessage(msgData as string); + } = parseAndNormalizeSignTypedData(msgData as string); const tokenDetails = extractTokenDetailsByPrimaryType(message, primaryType); diff --git a/app/components/Views/confirmations/components/title/title.tsx b/app/components/Views/confirmations/components/title/title.tsx index 84dacc865bfe..d22f27ab7902 100644 --- a/app/components/Views/confirmations/components/title/title.tsx +++ b/app/components/Views/confirmations/components/title/title.tsx @@ -12,7 +12,12 @@ import useApprovalRequest from '../../hooks/useApprovalRequest'; import { useSignatureRequest } from '../../hooks/signatures/useSignatureRequest'; import { useStandaloneConfirmation } from '../../hooks/ui/useStandaloneConfirmation'; import { useTransactionMetadataRequest } from '../../hooks/transactions/useTransactionMetadataRequest'; -import { isPermitDaiRevoke, isRecognizedPermit, isSIWESignatureRequest, parseTypedDataMessageFromSignatureRequest } from '../../utils/signature'; +import { + isPermitDaiRevoke, + isRecognizedPermit, + isSIWESignatureRequest, + parseAndNormalizeSignTypedDataFromSignatureRequest, +} from '../../utils/signature'; import { REDESIGNED_TRANSFER_TYPES } from '../../constants/confirmations'; import styleSheet from './title.styles'; @@ -40,9 +45,10 @@ const getTitleAndSubTitle = ( const isPermit = isRecognizedPermit(signatureRequest); if (isPermit) { - const parsedMessage = parseTypedDataMessageFromSignatureRequest(signatureRequest) ?? {}; - const { allowed, tokenId, value } = parsedMessage?.message ?? {}; - const { verifyingContract } = parsedMessage?.domain ?? {}; + const parsedData = + parseAndNormalizeSignTypedDataFromSignatureRequest(signatureRequest); + const { allowed, tokenId, value } = parsedData.message ?? {}; + const { verifyingContract } = parsedData.domain ?? {}; const isERC721Permit = tokenId !== undefined; if (isERC721Permit) { diff --git a/app/components/Views/confirmations/hooks/signatures/useSignatureMetrics.ts b/app/components/Views/confirmations/hooks/signatures/useSignatureMetrics.ts index c7304ec4ccec..183a83143ef9 100644 --- a/app/components/Views/confirmations/hooks/signatures/useSignatureMetrics.ts +++ b/app/components/Views/confirmations/hooks/signatures/useSignatureMetrics.ts @@ -14,7 +14,7 @@ import { getSignatureDecodingEventProps } from '../../utils/signature-metrics'; import { useSignatureRequest } from './useSignatureRequest'; import { useSecurityAlertResponse } from '../alerts/useSecurityAlertResponse'; import { useTypedSignSimulationEnabled } from './useTypedSignSimulationEnabled'; -import { parseTypedDataMessageFromSignatureRequest } from '../../utils/signature'; +import { parseAndNormalizeSignTypedDataFromSignatureRequest } from '../../utils/signature'; import { useSelector } from 'react-redux'; import { selectConfirmationMetricsById } from '../../../../../core/redux/slices/confirmationMetrics'; import { RootState } from '../../../../../reducers'; @@ -66,7 +66,7 @@ export const useSignatureMetrics = () => { const { chainId, decodingData, decodingLoading, messageParams, type, id } = signatureRequest ?? {}; - const { primaryType } = parseTypedDataMessageFromSignatureRequest(signatureRequest) ?? {}; + const { primaryType } = parseAndNormalizeSignTypedDataFromSignatureRequest(signatureRequest); const confirmationMetrics = useSelector((state: RootState) => selectConfirmationMetricsById(state, id ?? '') diff --git a/app/components/Views/confirmations/hooks/signatures/useTokenDecimalsInTypedSignRequest.test.ts b/app/components/Views/confirmations/hooks/signatures/useTokenDecimalsInTypedSignRequest.test.ts index b83365527041..39e223c3e7ce 100644 --- a/app/components/Views/confirmations/hooks/signatures/useTokenDecimalsInTypedSignRequest.test.ts +++ b/app/components/Views/confirmations/hooks/signatures/useTokenDecimalsInTypedSignRequest.test.ts @@ -2,7 +2,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { typedSignV4SignatureRequest } from '../../../../../util/test/confirm-data-helpers'; import { DataTreeInput } from '../../components/data-tree/data-tree'; -import { parseSanitizeTypedDataMessage } from '../../utils/signature'; +import { parseNormalizeAndSanitizeSignTypedData } from '../../utils/signature'; // eslint-disable-next-line import/no-namespace import * as TokenDecimalHook from '../useGetTokenStandardAndDetails'; import { useTokenDecimalsInTypedSignRequest } from './useTokenDecimalsInTypedSignRequest'; @@ -12,7 +12,7 @@ describe('useTokenDecimalsInTypedSignRequest', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const typedSignData = signatureRequest?.messageParams?.data as any; const { domain: { verifyingContract } = {}, sanitizedMessage } = - parseSanitizeTypedDataMessage(typedSignData); + parseNormalizeAndSanitizeSignTypedData(typedSignData); it('returns correct decimal value for typed sign signature request', () => { jest diff --git a/app/components/Views/confirmations/hooks/signatures/useTypedSignSimulationEnabled.ts b/app/components/Views/confirmations/hooks/signatures/useTypedSignSimulationEnabled.ts index dadb5afda6fe..3d1cf5a164c1 100644 --- a/app/components/Views/confirmations/hooks/signatures/useTypedSignSimulationEnabled.ts +++ b/app/components/Views/confirmations/hooks/signatures/useTypedSignSimulationEnabled.ts @@ -6,7 +6,7 @@ import { SignatureRequestType, } from '@metamask/signature-controller'; import { selectUseTransactionSimulations } from '../../../../../selectors/preferencesController'; -import { isRecognizedPermit, parseTypedDataMessage } from '../../utils/signature'; +import { isRecognizedPermit, parseAndNormalizeSignTypedData } from '../../utils/signature'; import { useSignatureRequest } from './useSignatureRequest'; const NON_PERMIT_SUPPORTED_TYPES_SIGNS = [ @@ -32,7 +32,7 @@ const isNonPermitSupportedByDecodingAPI = ( const { domain: { name, version }, primaryType, - } = parseTypedDataMessage(data); + } = parseAndNormalizeSignTypedData(data); return NON_PERMIT_SUPPORTED_TYPES_SIGNS.some( ({ domainName, primaryTypeList, versionList }) => diff --git a/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx b/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx index eaad2b9276b3..755a265b93e6 100644 --- a/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx +++ b/app/components/Views/confirmations/legacy/components/PersonalSign/PersonalSign.tsx @@ -12,7 +12,7 @@ import { isExternalHardwareAccount, stripHexPrefix, } from '../../../../../../util/address'; -import { sanitizeString } from '../../../../../../util/string'; +import { escapeSpecialUnicode } from '../../../../../../util/string'; import { useTheme } from '../../../../../../util/theme'; import { WALLET_CONNECT_ORIGIN } from '../../../../../../util/walletconnect'; import SignatureRequest from '../SignatureRequest'; @@ -210,7 +210,7 @@ const PersonalSign = ({ }; const renderMessageText = () => { - const textChild = sanitizeString(msgHexToText(messageParams.data)) + const textChild = escapeSpecialUnicode(msgHexToText(messageParams.data)) .split('\n') .map((line: string, i: number) => ( - {sanitizeString(key)}: + {escapeSpecialUnicode(key)}: {this.renderTypedMessageV3(obj[key])} ) : ( - {sanitizeString(key)}:{' '} - {sanitizeString(`${obj[key]}`)} + {escapeSpecialUnicode(key)}:{' '} + {escapeSpecialUnicode(`${obj[key]}`)} )} @@ -225,10 +223,10 @@ class TypedSign extends PureComponent { {messageParams.data.map((obj, i) => ( - {sanitizeString(obj.name)}: + {escapeSpecialUnicode(obj.name)}: - {sanitizeString(` ${obj.value}`)} + {escapeSpecialUnicode(` ${obj.value}`)} ))} @@ -236,8 +234,8 @@ class TypedSign extends PureComponent { ); } if (messageParams.version === 'V3' || messageParams.version === 'V4') { - const message = parseTypedSignDataMessage(messageParams.data); - return this.renderTypedMessageV3(message); + const { sanitizedMessage } = parseAndSanitizeSignTypedData(messageParams.data); + return this.renderTypedMessageV3(sanitizedMessage); } }; diff --git a/app/components/Views/confirmations/utils/signature.test.ts b/app/components/Views/confirmations/utils/signature.test.ts index 6c3523dc232c..00fc229ce75f 100644 --- a/app/components/Views/confirmations/utils/signature.test.ts +++ b/app/components/Views/confirmations/utils/signature.test.ts @@ -1,10 +1,11 @@ import { - parseTypedDataMessage, + parseAndNormalizeSignTypedData, isRecognizedPermit, isTypedSignV3V4Request, - parseTypedDataMessageFromSignatureRequest, isRecognizedOrder, - parseSanitizeTypedDataMessage, + sanitizeParsedMessage, + parseAndSanitizeSignTypedData, + parseAndNormalizeSignTypedDataFromSignatureRequest, } from './signature'; import { PRIMARY_TYPES_ORDER, @@ -50,14 +51,14 @@ const mockExpectedSanitizedTypedSignV3Message = { }; describe('Signature Utils', () => { - describe('parseTypedDataMessage', () => { + describe('parseAndNormalizeSignTypedData', () => { it('parses a typed data message correctly', () => { const data = JSON.stringify({ message: { value: '123', }, }); - const result = parseTypedDataMessage(data); + const result = parseAndNormalizeSignTypedData(data); expect(result).toEqual({ message: { value: '123', @@ -66,7 +67,7 @@ describe('Signature Utils', () => { }); it('parses message.value as a string', () => { - const result = parseTypedDataMessage( + const result = parseAndNormalizeSignTypedData( '{"test": "dummy", "message": { "value": 3000123} }', ); expect(result.message.value).toBe('3000123'); @@ -79,13 +80,13 @@ describe('Signature Utils', () => { value: largeValue, }, }); - const result = parseTypedDataMessage(data); + const result = parseAndNormalizeSignTypedData(data); expect(result.message.value).toBe(largeValue); }); it('throws an error for invalid typedDataMessage', () => { expect(() => { - parseTypedDataMessage(''); + parseAndNormalizeSignTypedData(''); }).toThrow(new Error('Unexpected end of JSON input')); }); }); @@ -173,31 +174,35 @@ describe('Signature Utils', () => { }); }); - describe('parseTypedDataMessageFromSignatureRequest', () => { + describe('parseAndNormalizeSignTypedDataFromSignatureRequest', () => { it('parses the correct primary type', () => { expect( - parseTypedDataMessageFromSignatureRequest(typedSignV3SignatureRequest)?.primaryType, + parseAndNormalizeSignTypedDataFromSignatureRequest(typedSignV3SignatureRequest)?.primaryType, ).toBe('Mail'); expect( - parseTypedDataMessageFromSignatureRequest(typedSignV4SignatureRequest)?.primaryType, + parseAndNormalizeSignTypedDataFromSignatureRequest(typedSignV4SignatureRequest)?.primaryType, ).toBe('Permit'); }); - it('parses undefined for typed sign V1 message', () => { + it('parses {} for typed sign V1 message', () => { expect( - parseTypedDataMessageFromSignatureRequest(typedSignV1SignatureRequest), - ).toBe(undefined); + parseAndNormalizeSignTypedDataFromSignatureRequest(typedSignV1SignatureRequest), + ).toStrictEqual({}); }); - it('parses undefined for personal sign message', () => { + it('parses {} for personal sign message', () => { expect( - parseTypedDataMessageFromSignatureRequest(personalSignSignatureRequest), - ).toBe(undefined); + parseAndNormalizeSignTypedDataFromSignatureRequest(personalSignSignatureRequest), + ).toStrictEqual({}); }); }); - describe('parseSanitizeTypedDataMessage', () => { + describe('parseAndSanitizeSignTypedData', () => { + const typedDataMsg = + '{"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF","0x06195827297c7A80a443b6894d3BDB8824b43896"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"primaryType":"Mail","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}'; + it('returns parsed and sanitized types signature message', () => { - const { sanitizedMessage, primaryType, domain } = - parseSanitizeTypedDataMessage(JSON.stringify(mockTypedSignV3Message)); + const parsedMessage = + parseAndSanitizeSignTypedData(JSON.stringify(mockTypedSignV3Message)); + const { primaryType, domain, sanitizedMessage } = parsedMessage; expect(primaryType).toBe('Mail'); expect(sanitizedMessage).toEqual(mockExpectedSanitizedTypedSignV3Message); @@ -205,8 +210,132 @@ describe('Signature Utils', () => { }); it('returns an empty object if no data is passed', () => { - const result = parseSanitizeTypedDataMessage(''); + const result = parseAndSanitizeSignTypedData(''); expect(result).toMatchObject({}); }); + + it('should throw an error if types is undefined', () => { + const typedDataMsgWithoutTypes = + '{"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF","0x06195827297c7A80a443b6894d3BDB8824b43896"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"primaryType":"Mail"}'; + expect(() => parseAndSanitizeSignTypedData(typedDataMsgWithoutTypes)).toThrow( + 'Invalid types definition', + ); + }); + + it('should throw an error if base type is not defined', () => { + const typedSignDataWithoutBaseType = + '{"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF","0x06195827297c7A80a443b6894d3BDB8824b43896"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}'; + expect(() => + parseAndSanitizeSignTypedData(typedSignDataWithoutBaseType), + ).toThrow('Invalid primary type definition'); + }); + + it('should return message data ignoring unknown types and trim new lines', () => { + const result = parseAndSanitizeSignTypedData(typedDataMsg); + expect(result.sanitizedMessage).toStrictEqual({ + value: { + contents: { value: 'Hello, Bob!', type: 'string' }, + from: { + value: { + name: { value: 'Cow', type: 'string' }, + wallets: { + value: [ + { + value: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + type: 'address', + }, + { + value: '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', + type: 'address', + }, + { + value: '0x06195827297c7A80a443b6894d3BDB8824b43896', + type: 'address', + }, + ], + type: 'address[]', + }, + }, + type: 'Person', + }, + to: { + value: [ + { + value: { + name: { value: 'Bob', type: 'string' }, + wallets: { + value: [ + { + value: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + type: 'address', + }, + { + value: '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57', + type: 'address', + }, + { + value: '0xB0B0b0b0b0b0B000000000000000000000000000', + type: 'address', + }, + ], + type: 'address[]', + }, + }, + type: 'Person', + }, + ], + type: 'Person[]', + }, + }, + type: 'Mail', + }); + }); + }); + + describe('sanitizeParsedMessage', () => { + it('throws an error if types is undefined', () => { + const { message, primaryType } = mockTypedSignV3Message; + expect(() => sanitizeParsedMessage(message, primaryType, undefined)).toThrow( + 'Invalid types definition', + ); + }); + + it('throws an error if base type is not defined', () => { + const { message, types } = mockTypedSignV3Message; + expect(() => sanitizeParsedMessage(message, '', types)).toThrow( + 'Invalid primary type definition', + ); + }); + + it('returns the message data without extraneous params missing matching type definitions', () => { + const { message, primaryType, types } = mockTypedSignV3Message; + const result = sanitizeParsedMessage(message, primaryType, types); + expect(result).toStrictEqual({ + value: { + from: { + value: { + name: { value: 'Cow', type: 'string' }, + wallet: { + value: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + type: 'address', + }, + }, + type: 'Person', + }, + to: { + value: { + name: { value: 'Bob', type: 'string' }, + wallet: { + value: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + type: 'address', + }, + }, + type: 'Person', + }, + contents: { value: 'Hello, Bob!', type: 'string' }, + }, + type: 'Mail', + }); + }); }); }); diff --git a/app/components/Views/confirmations/utils/signature.ts b/app/components/Views/confirmations/utils/signature.ts index 35880f8f1e38..47f494fe3df3 100644 --- a/app/components/Views/confirmations/utils/signature.ts +++ b/app/components/Views/confirmations/utils/signature.ts @@ -11,59 +11,32 @@ import { PRIMARY_TYPES_PERMIT, PrimaryType, } from '../constants/signatures'; -import { sanitizeMessage } from '../../../../util/string'; +import { + isArrayType, + isSolidityType, + stripArrayType, + stripMultipleNewlines, + stripOneLayerofNesting, + } from '../../../../util/string'; import { TOKEN_ADDRESS } from '../constants/tokens'; import BigNumber from 'bignumber.js'; -/** - * The contents of this file have been taken verbatim from - * metamask-extension/shared/modules/transaction.utils.ts - * - * If updating, please be mindful of this or delete this comment. - */ - -const REGEX_MESSAGE_VALUE_LARGE = - /"message"\s*:\s*\{[^}]*"value"\s*:\s*(\d{15,})/u; +type FieldValue = string | string[] | Record; -function extractLargeMessageValue(dataToParse: string): string | undefined { - if (typeof dataToParse !== 'string') { - return undefined; - } - return dataToParse.match(REGEX_MESSAGE_VALUE_LARGE)?.[1]; +interface BaseType { + name: string; + type: string; } - -/** - * JSON.parse has a limitation which coerces values to scientific notation if numbers are greater than - * Number.MAX_SAFE_INTEGER. This can cause a loss in precision. - * - * Aside from precision concerns, if the value returned was a large number greater than 15 digits, - * e.g. 3.000123123123121e+26, passing the value to BigNumber will throw the error: - * Error: new BigNumber() number type has more than 15 significant digits - * - * Note that using JSON.parse reviver cannot help since the value will be coerced by the time it - * reaches the reviver function. - * - * This function has a workaround to extract the large value from the message and replace - * the message value with the string value. - * - * @param dataToParse - * @returns - */ -export const parseTypedDataMessage = (dataToParse: string) => { - const result = JSON.parse(dataToParse); - - const messageValue = extractLargeMessageValue(dataToParse); - if (result.message?.value) { - result.message.value = messageValue || String(result.message.value); - } - return result; -}; - interface TypedSignatureRequest { messageParams: MessageParamsTyped; type: SignatureRequestType.TypedSign; } +interface ValueType { + value: FieldValue | ValueType[]; + type: string; +} + /** * Support backwards compatibility DAI while it's still being deprecated. See EIP-2612 for more info. */ @@ -108,23 +81,138 @@ export const isTypedSignV3V4Request = (signatureRequest?: SignatureRequest) => { ); }; -export const parseTypedDataMessageFromSignatureRequest = ( +/** + * This is a recursive method accepts a parsed, signTypedData message. It removes message params + * that do not have associated, valid solidity type definitions. It also strips multiple + * new lines in strings. + */ +export const sanitizeParsedMessage = ( + message: FieldValue, + primaryType: string, + types: Record | undefined, +): ValueType => { + if (!types) { + throw new Error(`Invalid types definition`); + } + + // Primary type can be an array. + const isArray = primaryType && isArrayType(primaryType); + if (isArray) { + return { + value: (message as string[]).map( + (value: string): ValueType => + sanitizeParsedMessage(value, stripOneLayerofNesting(primaryType), types), + ), + type: primaryType, + }; + } else if (isSolidityType(primaryType)) { + return { + value: stripMultipleNewlines(message) as ValueType['value'], + type: primaryType, + }; + } + + // If not, assume to be struct + const baseType = isArray ? stripArrayType(primaryType) : primaryType; + + const baseTypeDefinitions = types[baseType]; + if (!baseTypeDefinitions) { + throw new Error(`Invalid primary type definition`); + } + + const sanitizedStruct = {}; + const msgKeys = Object.keys(message); + msgKeys.forEach((msgKey: string) => { + const definedType: BaseType | undefined = Object.values( + baseTypeDefinitions, + ).find( + (baseTypeDefinition: BaseType) => baseTypeDefinition.name === msgKey, + ); + + if (!definedType) { + return; + } + + (sanitizedStruct as Record)[msgKey] = sanitizeParsedMessage( + (message as Record)[msgKey], + definedType.type, + types, + ); + }); + return { value: sanitizedStruct, type: primaryType }; +}; + + +const REGEX_MESSAGE_VALUE_LARGE = + /"message"\s*:\s*\{[^}]*"value"\s*:\s*(\d{15,})/u; + +/** Returns the value of the message if it is a digit greater than 15 digits */ +function extractLargeMessageValue(messageParamsData: string): string | undefined { + if (typeof messageParamsData !== 'string') { + return undefined; + } + return messageParamsData.match(REGEX_MESSAGE_VALUE_LARGE)?.[1]; +} + +/** + * JSON.parse has a limitation which coerces values to scientific notation if numbers are greater than + * Number.MAX_SAFE_INTEGER. This can cause a loss in precision. + * + * Aside from precision concerns, if the value returned was a large number greater than 15 digits, + * e.g. 3.000123123123121e+26, passing the value to BigNumber will throw the error: + * Error: new BigNumber() number type has more than 15 significant digits + * + * Note that using JSON.parse reviver cannot help since the value will be coerced by the time it + * reaches the reviver function. + * + * This function has a workaround to extract the large value from the message and replace + * the message value with the string value. + */ +export const parseAndNormalizeSignTypedData = (messageParamsData: string) => { + const result = JSON.parse(messageParamsData); + + const largeMessageValue = extractLargeMessageValue(messageParamsData); + if (result.message?.value) { + result.message.value = largeMessageValue || String(result.message.value); + } + + return result; +}; + +export const parseAndSanitizeSignTypedData = (messageParamsData: string) => { + if (!messageParamsData) { return {}; } + + const { domain, message, primaryType, types } = JSON.parse(messageParamsData); + const sanitizedMessage = sanitizeParsedMessage(message, primaryType, types); + + return { sanitizedMessage, primaryType, domain }; +}; + +export const parseNormalizeAndSanitizeSignTypedData = (messageParamsData: string) => { + if (!messageParamsData) { return {}; } + + const { domain, message, primaryType, types } = parseAndNormalizeSignTypedData(messageParamsData); + const sanitizedMessage = sanitizeParsedMessage(message, primaryType, types); + + return { sanitizedMessage, primaryType, domain }; +}; + +export const parseAndNormalizeSignTypedDataFromSignatureRequest = ( signatureRequest?: SignatureRequest, ) => { if (!signatureRequest || !isTypedSignV3V4Request(signatureRequest)) { - return; + return {}; } const data = signatureRequest.messageParams?.data as string; - return parseTypedDataMessage(data); + return parseAndNormalizeSignTypedData(data); }; const isRecognizedOfType = ( request: SignatureRequest | undefined, types: PrimaryType[], ) => { - const { primaryType } = - parseTypedDataMessageFromSignatureRequest(request) || {}; + const { primaryType } = parseAndNormalizeSignTypedDataFromSignatureRequest(request); return types.includes(primaryType); }; @@ -144,18 +232,6 @@ export const isRecognizedPermit = (request?: SignatureRequest) => export const isRecognizedOrder = (request?: SignatureRequest) => isRecognizedOfType(request, PRIMARY_TYPES_ORDER); -export const parseSanitizeTypedDataMessage = (dataToParse: string) => { - if (!dataToParse) { - return {}; - } - - const { domain, message, primaryType, types } = - parseTypedDataMessage(dataToParse); - - const sanitizedMessage = sanitizeMessage(message, primaryType, types); - return { sanitizedMessage, primaryType, domain }; -}; - export interface SIWEMessage { address: string; chainId: string; diff --git a/app/util/string/index.test.ts b/app/util/string/index.test.ts index f5d7c36acea5..88dae254a0d9 100644 --- a/app/util/string/index.test.ts +++ b/app/util/string/index.test.ts @@ -1,159 +1,105 @@ import { - parseTypedSignDataMessage, - sanitizeMessage, - sanitizeString, + escapeSpecialUnicode, + isArrayType, + isSolidityType, + stripArrayType, stripMultipleNewlines, + stripOneLayerofNesting, } from '.'; -import { mockTypedSignV3Message } from '../test/confirm-data-helpers'; describe('string utils', () => { - describe('sanitizeString', () => { + describe('escapeSpecialUnicode', () => { it('escapes all occurences of \u202E', () => { - const result = sanitizeString('test \u202E test \u202E test'); + const result = escapeSpecialUnicode('test \u202E test \u202E test'); expect(result).toEqual('test \\u202E test \\u202E test'); }); it('escapes all occurences of \u202D and \u202E', () => { - const result = sanitizeString('test \u202D test \u202E test \u202D test'); + const result = escapeSpecialUnicode('test \u202D test \u202E test \u202D test'); expect(result).toEqual('test \\u202D test \\u202E test \\u202D test'); }); }); - describe('stripMultipleNewlines', () => { - it('replace multiple newline characters in string with single newline character', async () => { - const result = stripMultipleNewlines( - 'Secure ✅ \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n', - ); - expect(result).toEqual('Secure ✅ \n'); + describe('isArrayType', () => { + [ + ['uint256[]', true], + ['address[5]', true], + ['string[][]', true], + ['bytes32[1][2]', true], + ['uint256', false], + ['address', false], + ['string', false], + ['bytes32', false], + ].forEach(([input, expected]) => { + it(`returns ${expected} for ${input}`, () => { + const result = isArrayType(input as string); + expect(result).toEqual(expected); + }); }); }); - describe('sanitizeMessage', () => { - it('should throw an error if types is undefined', () => { - const { message, primaryType } = mockTypedSignV3Message; - expect(() => sanitizeMessage(message, primaryType, undefined)).toThrow( - 'Invalid types definition', - ); + describe('isSolidityType', () => { + [ + ['uint256[]', false], + ['address[5]', false], + ['string[][]', false], + ['bytes32[1][2]', false], + ['uint256', true], + ['address', true], + ['string', true], + ['bytes32', true], + ].forEach(([input, expected]) => { + it(`returns ${expected} for ${input}`, () => { + const result = isSolidityType(input as string); + expect(result).toEqual(expected); + }); }); + }); - it('should throw an error if base type is not defined', () => { - const { message, types } = mockTypedSignV3Message; - expect(() => sanitizeMessage(message, '', types)).toThrow( - 'Invalid primary type definition', - ); + describe('stripArrayType', () => { + [ + ['uint256[]', 'uint256'], + ['address[5]', 'address'], + ['string[][]', 'string'], + ['bytes32[1][2]', 'bytes32'], + ].forEach(([input, expected]) => { + it(`removes the array type from ${input}`, () => { + const result = stripArrayType(input); + expect(result).toEqual(expected); + }); }); + }); - it('should return message data as expected', () => { - const { message, primaryType, types } = mockTypedSignV3Message; - const result = sanitizeMessage(message, primaryType, types); - expect(result).toStrictEqual({ - value: { - from: { - value: { - name: { value: 'Cow', type: 'string' }, - wallet: { - value: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', - type: 'address', - }, - }, - type: 'Person', - }, - to: { - value: { - name: { value: 'Bob', type: 'string' }, - wallet: { - value: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', - type: 'address', - }, - }, - type: 'Person', - }, - contents: { value: 'Hello, Bob!', type: 'string' }, - }, - type: 'Mail', + describe('stripOneLayerofNesting', () => { + [ + ['uint256[1]', 'uint256'], + ['address[5]', 'address'], + ['string[1][2]', 'string[2]'], + ['bytes32[1][2]', 'bytes32[2]'], + ].forEach(([input, expected]) => { + it(`removes one layer of array nesting from ${input}`, () => { + const result = stripOneLayerofNesting(input); + expect(result).toEqual(expected); }); }); }); - describe('parseTypedSignDataMessage', () => { - const typedDataMsg = - '{"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF","0x06195827297c7A80a443b6894d3BDB8824b43896"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"primaryType":"Mail","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}'; - - it('should throw an error if types is undefined', () => { - const typedDataMsgWithoutTypes = - '{"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF","0x06195827297c7A80a443b6894d3BDB8824b43896"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"primaryType":"Mail"}'; - expect(() => parseTypedSignDataMessage(typedDataMsgWithoutTypes)).toThrow( - 'Invalid types definition', + describe('stripMultipleNewlines', () => { + it('replace multiple newline characters in string with single newline character', async () => { + const result = stripMultipleNewlines( + 'Secure ✅ \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n', ); + expect(result).toEqual('Secure ✅ \n'); }); - it('should throw an error if base type is not defined', () => { - const typedSignDataWithoutBaseType = - '{"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF","0x06195827297c7A80a443b6894d3BDB8824b43896"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}'; - expect(() => - parseTypedSignDataMessage(typedSignDataWithoutBaseType), - ).toThrow('Invalid primary type definition'); + it('returns undefined if the input is undefined', async () => { + const result = stripMultipleNewlines(undefined); + expect(result).toEqual(undefined); }); - it('should return message data ignoring unknown types and trim new lines', () => { - const result = parseTypedSignDataMessage(typedDataMsg); - expect(result).toStrictEqual({ - value: { - contents: { value: 'Hello, Bob!', type: 'string' }, - from: { - value: { - name: { value: 'Cow', type: 'string' }, - wallets: { - value: [ - { - value: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', - type: 'address', - }, - { - value: '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', - type: 'address', - }, - { - value: '0x06195827297c7A80a443b6894d3BDB8824b43896', - type: 'address', - }, - ], - type: 'address[]', - }, - }, - type: 'Person', - }, - to: { - value: [ - { - value: { - name: { value: 'Bob', type: 'string' }, - wallets: { - value: [ - { - value: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', - type: 'address', - }, - { - value: '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57', - type: 'address', - }, - { - value: '0xB0B0b0b0b0b0B000000000000000000000000000', - type: 'address', - }, - ], - type: 'address[]', - }, - }, - type: 'Person', - }, - ], - type: 'Person[]', - }, - }, - type: 'Mail', - }); + it('returns the value as is if it is not a string', async () => { + const result = stripMultipleNewlines(123); + expect(result).toEqual(123); }); }); }); diff --git a/app/util/string/index.ts b/app/util/string/index.ts index bd60ae3fec21..405188afcad8 100644 --- a/app/util/string/index.ts +++ b/app/util/string/index.ts @@ -1,12 +1,12 @@ import { isString } from '../lodash'; /** - * The method escapes LTR and RTL override unicode in the string + * The method escapes left-to-right (LTR) and right-to-left (RTL) unicode characters in the string * * @param {string} str * @returns {(string|*)} escaped string or original param value */ -export const sanitizeString = (str: string): string => { +export const escapeSpecialUnicode = (str: string): string => { if (!str) { return str; } @@ -18,7 +18,7 @@ export const sanitizeString = (str: string): string => { return str.split('\u202D').join('\\u202D').split('\u202E').join('\\u202E'); }; -export const stripMultipleNewlines = (str: string): string => { +export const stripMultipleNewlines = (str: string | unknown): string | unknown => { if (!str || typeof str !== 'string') { return str; } @@ -72,86 +72,13 @@ const solidityTypes = () => { const SOLIDITY_TYPES = solidityTypes(); -const stripArrayType = (potentialArrayType: string) => +export const stripArrayType = (potentialArrayType: string) => potentialArrayType.replace(/\[[[0-9]*\]*/gu, ''); -const stripOneLayerofNesting = (potentialArrayType: string) => +export const stripOneLayerofNesting = (potentialArrayType: string) => potentialArrayType.replace(/\[(\d*)\]/u, ''); -const isArrayType = (potentialArrayType: string) => +export const isArrayType = (potentialArrayType: string) => potentialArrayType.match(/\[[[0-9]*\]*/u) !== null; -const isSolidityType = (type: string) => SOLIDITY_TYPES.includes(type); - -interface BaseType { - name: string; - type: string; -} - -type FieldValue = string | string[] | Record; - -interface ValueType { - value: FieldValue | ValueType[]; - type: string; -} - -export const sanitizeMessage = ( - message: FieldValue, - primaryType: string, - types: Record | undefined, -): ValueType => { - if (!types) { - throw new Error(`Invalid types definition`); - } - - // Primary type can be an array. - const isArray = primaryType && isArrayType(primaryType); - if (isArray) { - return { - value: (message as string[]).map( - (value: string): ValueType => - sanitizeMessage(value, stripOneLayerofNesting(primaryType), types), - ), - type: primaryType, - }; - } else if (isSolidityType(primaryType)) { - return { - value: stripMultipleNewlines(message as string), - type: primaryType, - }; - } - - // If not, assume to be struct - const baseType = isArray ? stripArrayType(primaryType) : primaryType; - - const baseTypeDefinitions = types[baseType]; - if (!baseTypeDefinitions) { - throw new Error(`Invalid primary type definition`); - } - - const sanitizedStruct = {}; - const msgKeys = Object.keys(message); - msgKeys.forEach((msgKey: string) => { - const definedType: BaseType | undefined = Object.values( - baseTypeDefinitions, - ).find( - (baseTypeDefinition: BaseType) => baseTypeDefinition.name === msgKey, - ); - - if (!definedType) { - return; - } - - (sanitizedStruct as Record)[msgKey] = sanitizeMessage( - (message as Record)[msgKey], - definedType.type, - types, - ); - }); - return { value: sanitizedStruct, type: primaryType }; -}; - -export const parseTypedSignDataMessage = (dataToParse: string) => { - const { message, primaryType, types } = JSON.parse(dataToParse); - return sanitizeMessage(message, primaryType, types); -}; +export const isSolidityType = (type: string) => SOLIDITY_TYPES.includes(type);