diff --git a/packages/eth-json-rpc-middleware/CHANGELOG.md b/packages/eth-json-rpc-middleware/CHANGELOG.md index c95e3a8da36..440a9b413b9 100644 --- a/packages/eth-json-rpc-middleware/CHANGELOG.md +++ b/packages/eth-json-rpc-middleware/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add prototype pollution validation for `signTypedData` methods (V1, V3, V4) to block dangerous properties (`__proto__`, `constructor`, `prototype`, etc.) in message data. ([#7732](https://github.com/MetaMask/core/pull/7732)) + ### Changed - Bump `@metamask/eth-block-tracker` from `^15.0.0` to `^15.0.1` ([#7642](https://github.com/MetaMask/core/pull/7642)) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index 01293dd6eeb..a29d8a13a29 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -3,6 +3,7 @@ import type { Struct, StructError } from '@metamask/superstruct'; import { validate } from '@metamask/superstruct'; import type { Hex } from '@metamask/utils'; +import { parseTypedMessage } from './normalize'; import type { WalletMiddlewareContext } from '../wallet'; /** @@ -96,3 +97,93 @@ function formatValidationError(error: StructError, message: string): string { ) .join('\n')}`; } + +export const DANGEROUS_PROTOTYPE_PROPERTIES = [ + '__proto__', + 'constructor', + 'prototype', + '__defineGetter__', + '__defineSetter__', + '__lookupGetter__', + '__lookupSetter__', +] as const; + +/** + * Checks if a property name is dangerous for prototype pollution. + * + * @param key - The property name to check + * @returns True if the property name is dangerous + */ +function isDangerousProperty(key: string): boolean { + return (DANGEROUS_PROTOTYPE_PROPERTIES as readonly string[]).includes(key); +} + +/** + * Recursively checks an object for dangerous prototype pollution properties. + * + * @param obj - The object to check + * @throws rpcErrors.invalidInput() if a dangerous property is found + */ +function checkObjectForPrototypePollution(obj: unknown): void { + if (obj === null || obj === undefined) { + return; + } + + if (Array.isArray(obj)) { + for (const item of obj) { + checkObjectForPrototypePollution(item); + } + return; + } + + if (typeof obj === 'object') { + for (const key of Object.getOwnPropertyNames( + obj as Record, + )) { + if (isDangerousProperty(key)) { + throw rpcErrors.invalidInput(); + } + checkObjectForPrototypePollution((obj as Record)[key]); + } + } +} + +/** + * Validates V1 typed data (array format) for prototype pollution attacks. + * V1 format: [{ type: 'string', name: 'fieldName', value: 'data' }, ...] + * + * @param data - The V1 typed data array to validate + * @throws rpcErrors.invalidInput() if prototype pollution is detected + */ +export function validateTypedDataV1ForPrototypePollution( + data: Record[], +): void { + if (!data || !Array.isArray(data)) { + return; + } + + for (const item of data) { + if (item && typeof item === 'object') { + // Only check the 'value' field (the message data) for dangerous properties + if (item.value !== null && typeof item.value === 'object') { + checkObjectForPrototypePollution(item.value); + } + } + } +} + +/** + * Validates V3/V4 typed data (EIP-712 format) for prototype pollution attacks. + * Only checks the message field for dangerous properties. + * + * @param data - The stringified typed data to validate + * @throws rpcErrors.invalidInput() if prototype pollution is detected + */ +export function validateTypedDataForPrototypePollution(data: string): void { + const { message } = parseTypedMessage(data); + + // Check message recursively for dangerous properties + if (message !== undefined) { + checkObjectForPrototypePollution(message); + } +} diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index 581eee55d80..03c456325f8 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -8,6 +8,7 @@ import type { TypedMessageV1Params, } from '.'; import { createWalletMiddleware } from '.'; +import { DANGEROUS_PROTOTYPE_PROPERTIES } from './utils/validation'; import { createHandleParams, createRequest } from '../test/util/helpers'; const testAddresses = [ @@ -903,4 +904,113 @@ describe('wallet', () => { expect(ecrecoverResult).toStrictEqual(signParams.addressHex); }); }); + + describe('prototype pollution validation', () => { + describe('signTypedData (V1)', () => { + DANGEROUS_PROTOTYPE_PROPERTIES.forEach((dangerousProperty) => { + it(`should throw if value contains nested ${dangerousProperty}`, async () => { + const getAccounts = async (): Promise => + testAddresses.slice(); + const processTypedMessage = async (): Promise => testMsgSig; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessage }), + ], + }); + + const value = {}; + Object.defineProperty(value, dangerousProperty, { + value: 'malicious', + enumerable: true, + }); + const message = [{ type: 'object', name: 'data', value }]; + const payload = { + method: 'eth_signTypedData', + params: [message, testAddresses[0]], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + }); + }); + + describe('signTypedDataV3', () => { + DANGEROUS_PROTOTYPE_PROPERTIES.forEach((dangerousProperty) => { + it(`should throw if message contains ${dangerousProperty}`, async () => { + const getAccounts = async (): Promise => + testAddresses.slice(); + const processTypedMessageV3 = async (): Promise => testMsgSig; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV3 }), + ], + }); + + const msgObj = {}; + Object.defineProperty(msgObj, dangerousProperty, { + value: 'malicious', + enumerable: true, + }); + const message = { + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + }, + primaryType: 'EIP712Domain', + domain: {}, + message: msgObj, + }; + + const payload = { + method: 'eth_signTypedData_v3', + params: [testAddresses[0], JSON.stringify(message)], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + }); + }); + + describe('signTypedDataV4', () => { + DANGEROUS_PROTOTYPE_PROPERTIES.forEach((dangerousProperty) => { + it(`should throw if message contains ${dangerousProperty}`, async () => { + const getAccounts = async (): Promise => + testAddresses.slice(); + const processTypedMessageV4 = async (): Promise => testMsgSig; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV4 }), + ], + }); + + const msgObj = {}; + Object.defineProperty(msgObj, dangerousProperty, { + value: 'malicious', + enumerable: true, + }); + const message = { + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + Permit: [{ name: 'owner', type: 'address' }], + }, + primaryType: 'Permit', + domain: {}, + message: msgObj, + }; + + const payload = { + method: 'eth_signTypedData_v4', + params: [testAddresses[0], JSON.stringify(message)], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + }); + }); + }); }); diff --git a/packages/eth-json-rpc-middleware/src/wallet.ts b/packages/eth-json-rpc-middleware/src/wallet.ts index f5893c22c44..24394190640 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.ts @@ -23,6 +23,8 @@ import { normalizeTypedMessage, parseTypedMessage } from './utils/normalize'; import { resemblesAddress, validateAndNormalizeKeyholder as validateKeyholder, + validateTypedDataForPrototypePollution, + validateTypedDataV1ForPrototypePollution, } from './utils/validation'; export type TransactionParams = { @@ -323,6 +325,7 @@ export function createWalletMiddleware({ const message = params[0]; const address = await validateAndNormalizeKeyholder(params[1], context); const version = 'V1'; + validateTypedDataV1ForPrototypePollution(message); // Not using nullish coalescing, since `params` may be `null`. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const extraParams = params[2] || {}; @@ -366,6 +369,7 @@ export function createWalletMiddleware({ const message = normalizeTypedMessage(params[1]); validatePrimaryType(message); validateVerifyingContract(message); + validateTypedDataForPrototypePollution(message); const version = 'V3'; const msgParams: TypedMessageParams = { data: message, @@ -406,6 +410,7 @@ export function createWalletMiddleware({ const message = normalizeTypedMessage(params[1]); validatePrimaryType(message); validateVerifyingContract(message); + validateTypedDataForPrototypePollution(message); const version = 'V4'; const msgParams: TypedMessageParams = { data: message,