From e4570f51e3c9e4c380d15ec8f09254fb96e51085 Mon Sep 17 00:00:00 2001 From: Ogulcan Poyraz Date: Mon, 13 Jul 2026 11:25:24 +0300 Subject: [PATCH 01/17] feat(eth-json-rpc-middleware): validate eth_sendTransaction / eth_signTransaction params Reject requests whose top-level params contain keys outside the allowlisted transaction fields or nest beyond MAX_TRANSACTION_PARAM_DEPTH (10). Prevents downstream normalization / PPOM WASM from crashing with RangeError on deeply-nested junk fields and silently bypassing security scans. Mirrors the guardrail pattern introduced for eth_signTypedData_v4 in #8526. CONF-1662 --- packages/eth-json-rpc-middleware/CHANGELOG.md | 1 + .../src/utils/validation.test.ts | 125 ++++++++++++++++++ .../src/utils/validation.ts | 98 ++++++++++++++ .../src/wallet.test.ts | 117 ++++++++++++++++ .../eth-json-rpc-middleware/src/wallet.ts | 3 + 5 files changed, 344 insertions(+) diff --git a/packages/eth-json-rpc-middleware/CHANGELOG.md b/packages/eth-json-rpc-middleware/CHANGELOG.md index a6823832a4..0ff955199b 100644 --- a/packages/eth-json-rpc-middleware/CHANGELOG.md +++ b/packages/eth-json-rpc-middleware/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params — reject requests whose params contain extraneous top-level keys or nest beyond `MAX_TRANSACTION_PARAM_DEPTH` (10). Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields and silently bypassing security scans. - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.5.0` ([#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) - Bump `@metamask/message-manager` from `^14.1.1` to `^14.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755)) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts index 230b476a4c..198133b3cd 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -5,9 +5,11 @@ import { any, validate } from '@metamask/superstruct'; import type { WalletMiddlewareKeyValues } from '../wallet'; import { + MAX_TRANSACTION_PARAM_DEPTH, resemblesAddress, validateAndNormalizeKeyholder, validateParams, + validateTransactionParams, validateTypedMessageKeys, } from './validation'; @@ -278,4 +280,127 @@ describe('Validation Utils', () => { }); }); }); + + describe('validateTransactionParams', () => { + const VALID_FROM = '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb'; + const VALID_TO = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; + + it('does not throw for minimal valid params', () => { + expect(() => + validateTransactionParams({ from: VALID_FROM }), + ).not.toThrow(); + }); + + it('does not throw for the full allowlisted param set', () => { + expect(() => + validateTransactionParams({ + accessList: [ + { + address: VALID_TO, + storageKeys: ['0x00', '0x01'], + }, + ], + authorizationList: [ + { + chainId: '0x1', + address: VALID_TO, + nonce: '0x0', + }, + ], + chainId: '0x1', + data: '0x095ea7b3', + from: VALID_FROM, + gas: '0x5208', + gasLimit: '0x5208', + gasPrice: '0x1', + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + nonce: '0x0', + to: VALID_TO, + type: '0x2', + value: '0x0', + }), + ).not.toThrow(); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['a string', 'not-an-object'], + ['a number', 42], + ['a boolean', true], + ['an array', [{ from: VALID_FROM }]], + ])('throws when params is %s', (_label, value) => { + expect(() => validateTransactionParams(value)).toThrow('Invalid input.'); + }); + + it('throws for an extraneous top-level key', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + extraKey: 'unexpected', + }), + ).toThrow('Invalid input.'); + }); + + it('throws for the incident repro payload (deeply-nested junk field)', () => { + let junk: Record = {}; + for (let i = 0; i < 1200; i++) { + junk = { b: junk }; + } + + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + value: '0x0', + data: '0x095ea7b3', + test: junk, + }), + ).toThrow('Invalid input.'); + }); + + it('throws when an allowlisted field nests beyond the depth limit', () => { + let deep: Record = { leaf: true }; + for (let i = 0; i < MAX_TRANSACTION_PARAM_DEPTH + 5; i++) { + deep = { nested: deep }; + } + + expect(() => + validateTransactionParams({ + from: VALID_FROM, + data: deep as unknown as string, + }), + ).toThrow('Invalid input.'); + }); + + it('throws when a deeply-nested array exceeds the depth limit', () => { + let deepArray: unknown = 'leaf'; + for (let i = 0; i < MAX_TRANSACTION_PARAM_DEPTH + 5; i++) { + deepArray = [deepArray]; + } + + expect(() => + validateTransactionParams({ + from: VALID_FROM, + accessList: deepArray as never, + }), + ).toThrow('Invalid input.'); + }); + + it('does not throw when params sit exactly at the depth limit', () => { + let deep: unknown = 'leaf'; + for (let i = 0; i < MAX_TRANSACTION_PARAM_DEPTH - 1; i++) { + deep = { nested: deep }; + } + + expect(() => + validateTransactionParams({ + from: VALID_FROM, + data: deep, + }), + ).not.toThrow(); + }); + }); }); diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index de3782fa67..04af3e6c6c 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -234,3 +234,101 @@ export function validateTypedMessageKeys(data: string): void { } } } + +/** + * Top-level keys explicitly permitted on `eth_sendTransaction` and + * `eth_signTransaction` params. Any additional top-level key causes the + * request to be rejected before it reaches downstream consumers such as PPOM + * or the Security Alerts API. + * + * Derived from the dapp-facing subset of `TransactionParams` in + * `@metamask/transaction-controller`. Internal-only fields + * (`estimateGasError`, `estimatedBaseFee`, `estimateSuggested`, + * `estimateUsed`, `gasUsed`) are intentionally omitted — dapps should not be + * able to inject them. + */ +export const ALLOWED_TRANSACTION_PARAM_KEYS = new Set([ + 'accessList', + 'authorizationList', + 'chainId', + 'data', + 'from', + 'gas', + 'gasLimit', + 'gasPrice', + 'maxFeePerGas', + 'maxPriorityFeePerGas', + 'nonce', + 'to', + 'type', + 'value', +]); + +/** + * Maximum nesting depth permitted anywhere inside a transaction params + * object. Legitimate params (including `accessList` and + * `authorizationList`) are at most ~4 levels deep. Anything beyond this is + * treated as a denial-of-service attempt against downstream normalization + * (which recurses and can overflow the call stack in native/WASM code). + */ +export const MAX_TRANSACTION_PARAM_DEPTH = 10; + +/** + * Recursively checks that a value does not nest beyond + * `MAX_TRANSACTION_PARAM_DEPTH`. + * + * @param value - The value to check. + * @param depth - The current depth. Callers should pass `0`. + * @throws rpcErrors.invalidInput() if the value nests too deeply. + */ +function assertMaxDepth(value: unknown, depth: number): void { + if (depth > MAX_TRANSACTION_PARAM_DEPTH) { + throw rpcErrors.invalidInput(); + } + + if (value === null || typeof value !== 'object') { + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + assertMaxDepth(item, depth + 1); + } + return; + } + + for (const key of Object.getOwnPropertyNames( + value as Record, + )) { + assertMaxDepth((value as Record)[key], depth + 1); + } +} + +/** + * Validates that `eth_sendTransaction` / `eth_signTransaction` params contain + * only spec-defined top-level keys and no excessively-nested structures. + * + * This guards against malicious dapps attaching deeply-nested junk fields + * (e.g. `{ from, to, data, test: { b: { b: { b: /* ~1200 levels *\/ } } } }`) + * that would otherwise crash downstream normalization or PPOM WASM with a + * `RangeError: Maximum call stack size exceeded`, bypassing security checks. + * + * @param params - The transaction params object supplied by the dapp. + * @throws rpcErrors.invalidInput() if params is not a plain object, contains + * an extraneous top-level key, or nests beyond `MAX_TRANSACTION_PARAM_DEPTH`. + */ +export function validateTransactionParams(params: unknown): void { + if (params === null || typeof params !== 'object' || Array.isArray(params)) { + throw rpcErrors.invalidInput(); + } + + const hasExtraneousKey = Object.keys(params).some( + (key) => !ALLOWED_TRANSACTION_PARAM_KEYS.has(key), + ); + + if (hasExtraneousKey) { + throw rpcErrors.invalidInput(); + } + + assertMaxDepth(params, 0); +} diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index a9964c0d3a..25ed91e977 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -1,5 +1,6 @@ import { MessageTypes, TypedMessage } from '@metamask/eth-sig-util'; import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { Json } from '@metamask/utils'; import type { MessageParams, @@ -150,6 +151,64 @@ describe('wallet', () => { ); }); + it('throws when params contain an extraneous top-level key', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const processTransaction = async (): Promise => testTxHash; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTransaction }), + ], + }); + const payload = { + method: 'eth_sendTransaction', + params: [ + { + from: testAddresses[0], + to: testAddresses[1], + extraKey: 'unexpected', + }, + ], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + + it('throws for the incident repro payload with deeply-nested junk', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const processTransaction = async (): Promise => testTxHash; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTransaction }), + ], + }); + + let junk: Json = {}; + for (let i = 0; i < 1200; i++) { + junk = { b: junk }; + } + + const payload = { + method: 'eth_sendTransaction', + params: [ + { + from: testAddresses[0], + to: testAddresses[1], + value: '0x0', + data: '0x095ea7b3', + test: junk, + }, + ] as Json[], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + it('should not override other request params', async () => { const getAccounts = async (): Promise => testAddresses.slice(0, 2); @@ -287,6 +346,64 @@ describe('wallet', () => { 'The requested account and/or method has not been authorized by the user.', ); }); + + it('throws when params contain an extraneous top-level key', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const processSignTransaction = async (): Promise => testTxHash; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processSignTransaction }), + ], + }); + const payload = { + method: 'eth_signTransaction', + params: [ + { + from: testAddresses[0], + to: testAddresses[1], + extraKey: 'unexpected', + }, + ], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + + it('throws for the incident repro payload with deeply-nested junk', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const processSignTransaction = async (): Promise => testTxHash; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processSignTransaction }), + ], + }); + + let junk: Json = {}; + for (let i = 0; i < 1200; i++) { + junk = { b: junk }; + } + + const payload = { + method: 'eth_signTransaction', + params: [ + { + from: testAddresses[0], + to: testAddresses[1], + value: '0x0', + data: '0x095ea7b3', + test: junk, + }, + ] as Json[], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); }); describe('signTypedData', () => { diff --git a/packages/eth-json-rpc-middleware/src/wallet.ts b/packages/eth-json-rpc-middleware/src/wallet.ts index b220be16e0..eed733a639 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.ts @@ -23,6 +23,7 @@ import { normalizeTypedMessage, parseTypedMessage } from './utils/normalize'; import { resemblesAddress, validateAndNormalizeKeyholder as validateKeyholder, + validateTransactionParams, validateTypedDataForPrototypePollution, validateTypedDataV1ForPrototypePollution, validateTypedMessageKeys, @@ -249,6 +250,7 @@ export function createWalletMiddleware({ } const params = request.params[0] as TransactionParams | undefined; + validateTransactionParams(params); const txParams: TransactionParams = { ...params, // Not using nullish coalescing, since `params` may be `null`. @@ -282,6 +284,7 @@ export function createWalletMiddleware({ } const params = request.params[0] as TransactionParams | undefined; + validateTransactionParams(params); const txParams: TransactionParams = { ...params, // Not using nullish coalescing, since `params` may be `null`. From 8a62471e57c4b7ff6e07dbeb878173bd3feb4f82 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 13 Jul 2026 11:41:32 +0300 Subject: [PATCH 02/17] Update --- packages/eth-json-rpc-middleware/src/index.test.ts | 1 + packages/eth-json-rpc-middleware/src/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/eth-json-rpc-middleware/src/index.test.ts b/packages/eth-json-rpc-middleware/src/index.test.ts index ff6ab44352..beece96550 100644 --- a/packages/eth-json-rpc-middleware/src/index.test.ts +++ b/packages/eth-json-rpc-middleware/src/index.test.ts @@ -296,6 +296,7 @@ describe('index module', () => { "createWalletMiddleware": [Function], "providerAsMiddleware": [Function], "providerAsMiddlewareV2": [Function], + "validateTransactionParams": [Function], } `); }); diff --git a/packages/eth-json-rpc-middleware/src/index.ts b/packages/eth-json-rpc-middleware/src/index.ts index 8efff9e55d..a933d1669d 100644 --- a/packages/eth-json-rpc-middleware/src/index.ts +++ b/packages/eth-json-rpc-middleware/src/index.ts @@ -35,4 +35,5 @@ export { } from './methods/wallet-get-supported-execution-permissions'; export * from './providerAsMiddleware'; export * from './retryOnEmpty'; +export { validateTransactionParams } from './utils/validation'; export * from './wallet'; From c138500d107d809fb2c72a5a58d3274abc4d72dd Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 13 Jul 2026 11:45:28 +0300 Subject: [PATCH 03/17] Update changelog --- packages/eth-json-rpc-middleware/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eth-json-rpc-middleware/CHANGELOG.md b/packages/eth-json-rpc-middleware/CHANGELOG.md index 0ff955199b..e8bf13969b 100644 --- a/packages/eth-json-rpc-middleware/CHANGELOG.md +++ b/packages/eth-json-rpc-middleware/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params — reject requests whose params contain extraneous top-level keys or nest beyond `MAX_TRANSACTION_PARAM_DEPTH` (10). Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields and silently bypassing security scans. +- Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params — reject requests whose params contain extraneous top-level keys or nest beyond `MAX_TRANSACTION_PARAM_DEPTH` (10). Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields and silently bypassing security scans. ([9482](https://github.com/MetaMask/core/pull/9482)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.5.0` ([#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) - Bump `@metamask/message-manager` from `^14.1.1` to `^14.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755)) From 42e4beed5357a960df9f0aa0f2266a898a9337ad Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 13 Jul 2026 11:58:52 +0300 Subject: [PATCH 04/17] Fix changelog --- packages/eth-json-rpc-middleware/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eth-json-rpc-middleware/CHANGELOG.md b/packages/eth-json-rpc-middleware/CHANGELOG.md index e8bf13969b..3e2467f183 100644 --- a/packages/eth-json-rpc-middleware/CHANGELOG.md +++ b/packages/eth-json-rpc-middleware/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params — reject requests whose params contain extraneous top-level keys or nest beyond `MAX_TRANSACTION_PARAM_DEPTH` (10). Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields and silently bypassing security scans. ([9482](https://github.com/MetaMask/core/pull/9482)) +- Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params — reject requests whose params contain extraneous top-level keys or nest beyond `MAX_TRANSACTION_PARAM_DEPTH` (10). Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields and silently bypassing security scans. ([#9482](https://github.com/MetaMask/core/pull/9482)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.5.0` ([#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) - Bump `@metamask/message-manager` from `^14.1.1` to `^14.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755)) From 186f262ac7b961ef7ccee5408b46489770ce27ad Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 13:58:27 +0300 Subject: [PATCH 05/17] Update --- packages/eth-json-rpc-middleware/CHANGELOG.md | 2 +- .../src/utils/validation.test.ts | 96 +++++++++--- .../src/utils/validation.ts | 139 +++++++----------- .../src/wallet.test.ts | 16 +- 4 files changed, 131 insertions(+), 122 deletions(-) diff --git a/packages/eth-json-rpc-middleware/CHANGELOG.md b/packages/eth-json-rpc-middleware/CHANGELOG.md index 3e2467f183..806b155806 100644 --- a/packages/eth-json-rpc-middleware/CHANGELOG.md +++ b/packages/eth-json-rpc-middleware/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params — reject requests whose params contain extraneous top-level keys or nest beyond `MAX_TRANSACTION_PARAM_DEPTH` (10). Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields and silently bypassing security scans. ([#9482](https://github.com/MetaMask/core/pull/9482)) +- Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params — reject requests whose params do not match the transaction schema (extraneous top-level keys, ill-typed fields such as non-hex `to`/`data`, malformed `accessList` / `authorizationList` entries) or exceed `MAX_TRANSACTION_PARAMS_SIZE_BYTES` when serialized. Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields or padded payloads and silently bypassing security scans. ([#9482](https://github.com/MetaMask/core/pull/9482)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.5.0` ([#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) - Bump `@metamask/message-manager` from `^14.1.1` to `^14.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755)) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts index 198133b3cd..17998eec66 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -5,7 +5,7 @@ import { any, validate } from '@metamask/superstruct'; import type { WalletMiddlewareKeyValues } from '../wallet'; import { - MAX_TRANSACTION_PARAM_DEPTH, + MAX_TRANSACTION_PARAMS_SIZE_BYTES, resemblesAddress, validateAndNormalizeKeyholder, validateParams, @@ -283,7 +283,14 @@ describe('Validation Utils', () => { describe('validateTransactionParams', () => { const VALID_FROM = '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb'; - const VALID_TO = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; + const VALID_TO = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + + beforeEach(() => { + const actual = jest.requireActual<{ + validate: typeof validate; + }>('@metamask/superstruct'); + validateMock.mockImplementation(actual.validate); + }); it('does not throw for minimal valid params', () => { expect(() => @@ -291,7 +298,7 @@ describe('Validation Utils', () => { ).not.toThrow(); }); - it('does not throw for the full allowlisted param set', () => { + it('does not throw for the full valid param set', () => { expect(() => validateTransactionParams({ accessList: [ @@ -331,7 +338,9 @@ describe('Validation Utils', () => { ['a boolean', true], ['an array', [{ from: VALID_FROM }]], ])('throws when params is %s', (_label, value) => { - expect(() => validateTransactionParams(value)).toThrow('Invalid input.'); + expect(() => validateTransactionParams(value)).toThrow( + /Invalid params|Invalid input/u, + ); }); it('throws for an extraneous top-level key', () => { @@ -341,7 +350,7 @@ describe('Validation Utils', () => { to: VALID_TO, extraKey: 'unexpected', }), - ).toThrow('Invalid input.'); + ).toThrow(/Invalid params/u); }); it('throws for the incident repro payload (deeply-nested junk field)', () => { @@ -358,47 +367,86 @@ describe('Validation Utils', () => { data: '0x095ea7b3', test: junk, }), - ).toThrow('Invalid input.'); + ).toThrow(/Invalid params|Invalid input/u); }); - it('throws when an allowlisted field nests beyond the depth limit', () => { - let deep: Record = { leaf: true }; - for (let i = 0; i < MAX_TRANSACTION_PARAM_DEPTH + 5; i++) { - deep = { nested: deep }; - } + it('throws when a typed field has the wrong type', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: { nested: 'not-an-address' }, + }), + ).toThrow(/Invalid params/u); + }); + it('throws when `to` is not a hex address', () => { expect(() => validateTransactionParams({ from: VALID_FROM, - data: deep as unknown as string, + to: 'not-an-address', + }), + ).toThrow(/Invalid params/u); + }); + + it('throws when `data` is not a hex string', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + data: 1234 as unknown as string, + }), + ).toThrow(/Invalid params/u); + }); + + it('throws when `accessList` entries are malformed', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + accessList: [{ address: 'not-hex', storageKeys: 'not-an-array' }], + }), + ).toThrow(/Invalid params/u); + }); + + it('throws for a data-padding attack that passes the schema', () => { + const padded = `0x${'00'.repeat(MAX_TRANSACTION_PARAMS_SIZE_BYTES)}`; + + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + data: padded, }), ).toThrow('Invalid input.'); }); - it('throws when a deeply-nested array exceeds the depth limit', () => { - let deepArray: unknown = 'leaf'; - for (let i = 0; i < MAX_TRANSACTION_PARAM_DEPTH + 5; i++) { - deepArray = [deepArray]; - } + it('throws for an accessList-padding attack that passes the schema', () => { + const padded = Array.from( + { length: Math.ceil(MAX_TRANSACTION_PARAMS_SIZE_BYTES / 64) }, + () => ({ + address: VALID_TO, + storageKeys: [`0x${'00'.repeat(32)}`], + }), + ); expect(() => validateTransactionParams({ from: VALID_FROM, - accessList: deepArray as never, + to: VALID_TO, + accessList: padded, }), ).toThrow('Invalid input.'); }); - it('does not throw when params sit exactly at the depth limit', () => { - let deep: unknown = 'leaf'; - for (let i = 0; i < MAX_TRANSACTION_PARAM_DEPTH - 1; i++) { - deep = { nested: deep }; - } + it('does not throw for a legitimate multi-entry accessList well under the size limit', () => { + const entries = Array.from({ length: 16 }, () => ({ + address: VALID_TO, + storageKeys: [`0x${'11'.repeat(32)}`, `0x${'22'.repeat(32)}`], + })); expect(() => validateTransactionParams({ from: VALID_FROM, - data: deep, + to: VALID_TO, + accessList: entries, }), ).not.toThrow(); }); diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index 04af3e6c6c..7284908fbb 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -1,8 +1,9 @@ import { TYPED_MESSAGE_SCHEMA } from '@metamask/eth-sig-util'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { Struct, StructError } from '@metamask/superstruct'; -import { validate } from '@metamask/superstruct'; +import { array, object, optional, validate } from '@metamask/superstruct'; import type { Hex } from '@metamask/utils'; +import { HexAddressStruct, StrictHexStruct } from '@metamask/utils'; import type { WalletMiddlewareContext } from '../wallet'; import { parseTypedMessage } from './normalize'; @@ -235,100 +236,64 @@ export function validateTypedMessageKeys(data: string): void { } } -/** - * Top-level keys explicitly permitted on `eth_sendTransaction` and - * `eth_signTransaction` params. Any additional top-level key causes the - * request to be rejected before it reaches downstream consumers such as PPOM - * or the Security Alerts API. - * - * Derived from the dapp-facing subset of `TransactionParams` in - * `@metamask/transaction-controller`. Internal-only fields - * (`estimateGasError`, `estimatedBaseFee`, `estimateSuggested`, - * `estimateUsed`, `gasUsed`) are intentionally omitted — dapps should not be - * able to inject them. - */ -export const ALLOWED_TRANSACTION_PARAM_KEYS = new Set([ - 'accessList', - 'authorizationList', - 'chainId', - 'data', - 'from', - 'gas', - 'gasLimit', - 'gasPrice', - 'maxFeePerGas', - 'maxPriorityFeePerGas', - 'nonce', - 'to', - 'type', - 'value', -]); - -/** - * Maximum nesting depth permitted anywhere inside a transaction params - * object. Legitimate params (including `accessList` and - * `authorizationList`) are at most ~4 levels deep. Anything beyond this is - * treated as a denial-of-service attempt against downstream normalization - * (which recurses and can overflow the call stack in native/WASM code). - */ -export const MAX_TRANSACTION_PARAM_DEPTH = 10; - -/** - * Recursively checks that a value does not nest beyond - * `MAX_TRANSACTION_PARAM_DEPTH`. - * - * @param value - The value to check. - * @param depth - The current depth. Callers should pass `0`. - * @throws rpcErrors.invalidInput() if the value nests too deeply. - */ -function assertMaxDepth(value: unknown, depth: number): void { - if (depth > MAX_TRANSACTION_PARAM_DEPTH) { - throw rpcErrors.invalidInput(); - } - - if (value === null || typeof value !== 'object') { - return; - } - - if (Array.isArray(value)) { - for (const item of value) { - assertMaxDepth(item, depth + 1); - } - return; - } - - for (const key of Object.getOwnPropertyNames( - value as Record, - )) { - assertMaxDepth((value as Record)[key], depth + 1); - } -} +const AccessListEntryStruct = object({ + address: HexAddressStruct, + storageKeys: array(StrictHexStruct), +}); + +const AuthorizationListEntryStruct = object({ + address: HexAddressStruct, + chainId: StrictHexStruct, + nonce: StrictHexStruct, + r: optional(StrictHexStruct), + s: optional(StrictHexStruct), + yParity: optional(StrictHexStruct), +}); + +export const TransactionParamsStruct = object({ + accessList: optional(array(AccessListEntryStruct)), + authorizationList: optional(array(AuthorizationListEntryStruct)), + chainId: optional(StrictHexStruct), + data: optional(StrictHexStruct), + from: HexAddressStruct, + gas: optional(StrictHexStruct), + gasLimit: optional(StrictHexStruct), + gasPrice: optional(StrictHexStruct), + maxFeePerGas: optional(StrictHexStruct), + maxPriorityFeePerGas: optional(StrictHexStruct), + nonce: optional(StrictHexStruct), + to: optional(HexAddressStruct), + type: optional(StrictHexStruct), + value: optional(StrictHexStruct), +}); + +export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 128 * 1024; /** - * Validates that `eth_sendTransaction` / `eth_signTransaction` params contain - * only spec-defined top-level keys and no excessively-nested structures. + * Validates `eth_sendTransaction` / `eth_signTransaction` params against the + * standard transaction schema and rejects payloads whose serialized size + * exceeds `MAX_TRANSACTION_PARAMS_SIZE_BYTES`. * - * This guards against malicious dapps attaching deeply-nested junk fields - * (e.g. `{ from, to, data, test: { b: { b: { b: /* ~1200 levels *\/ } } } }`) - * that would otherwise crash downstream normalization or PPOM WASM with a - * `RangeError: Maximum call stack size exceeded`, bypassing security checks. + * Guards against two attack shapes: + * 1. Structural: extraneous top-level keys or ill-typed nested values + * (e.g. `{ from, to, data, test: { b: { b: ... } } }`) that would crash + * downstream normalization / PPOM WASM with `RangeError: Maximum call + * stack size exceeded`, silently bypassing security checks. + * 2. Size: valid-shaped but oversized payloads (e.g. `data` padded with + * millions of hex zeros, or `accessList` with millions of entries) that + * exhaust memory / stack in the same downstream code. * * @param params - The transaction params object supplied by the dapp. - * @throws rpcErrors.invalidInput() if params is not a plain object, contains - * an extraneous top-level key, or nests beyond `MAX_TRANSACTION_PARAM_DEPTH`. + * @throws rpcErrors.invalidInput() if params does not match the schema or + * exceeds the size limit. + * @throws rpcErrors.invalidParams() with a Superstruct failure summary if + * the schema mismatch is on a typed field. */ export function validateTransactionParams(params: unknown): void { - if (params === null || typeof params !== 'object' || Array.isArray(params)) { - throw rpcErrors.invalidInput(); - } - - const hasExtraneousKey = Object.keys(params).some( - (key) => !ALLOWED_TRANSACTION_PARAM_KEYS.has(key), - ); - - if (hasExtraneousKey) { + const serializedSize = JSON.stringify(params ?? null).length; + if (serializedSize > MAX_TRANSACTION_PARAMS_SIZE_BYTES) { throw rpcErrors.invalidInput(); } - assertMaxDepth(params, 0); + validateParams(params, TransactionParamsStruct); } diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index 25ed91e977..ee4550add9 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -116,9 +116,7 @@ describe('wallet', () => { method: 'eth_sendTransaction', params: [txParams], }); - await expect(engine.handle(payload)).rejects.toThrow( - new Error('Invalid parameters: must provide an Ethereum address.'), - ); + await expect(engine.handle(payload)).rejects.toThrow(/Invalid params/u); }); it('throws unauthorized for unknown addresses', async () => { @@ -173,7 +171,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow('Invalid input.'); + ).rejects.toThrow(/Invalid params/u); }); it('throws for the incident repro payload with deeply-nested junk', async () => { @@ -206,7 +204,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow('Invalid input.'); + ).rejects.toThrow(/Invalid params|Invalid input/u); }); it('should not override other request params', async () => { @@ -315,9 +313,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow( - new Error('Invalid parameters: must provide an Ethereum address.'), - ); + ).rejects.toThrow(/Invalid params/u); }); it('should throw when provided unknown address', async () => { @@ -369,7 +365,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow('Invalid input.'); + ).rejects.toThrow(/Invalid params/u); }); it('throws for the incident repro payload with deeply-nested junk', async () => { @@ -402,7 +398,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow('Invalid input.'); + ).rejects.toThrow(/Invalid params|Invalid input/u); }); }); From 4f96cf56e6121e83198db1332ae9bb91dd7fa232 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 14:10:05 +0300 Subject: [PATCH 06/17] Update size --- .../src/utils/validation.test.ts | 34 +++++++++++- .../src/utils/validation.ts | 53 ++++++++++++++----- .../src/wallet.test.ts | 8 +-- 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts index 17998eec66..caa86a8664 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -350,7 +350,7 @@ describe('Validation Utils', () => { to: VALID_TO, extraKey: 'unexpected', }), - ).toThrow(/Invalid params/u); + ).toThrow('Invalid input.'); }); it('throws for the incident repro payload (deeply-nested junk field)', () => { @@ -367,7 +367,37 @@ describe('Validation Utils', () => { data: '0x095ea7b3', test: junk, }), - ).toThrow(/Invalid params|Invalid input/u); + ).toThrow('Invalid input.'); + }); + + it('rejects an extraneous top-level key without walking its value (no JSON.stringify, no Superstruct)', () => { + const stringifySpy = jest.spyOn(JSON, 'stringify'); + validateMock.mockClear(); + + const params = { + from: VALID_FROM, + to: VALID_TO, + test: { + get b(): never { + throw new Error('subtree must not be walked'); + }, + }, + }; + + let thrown: unknown; + try { + validateTransactionParams(params); + } catch (error) { + thrown = error; + } + const stringifyCallsAtRejection = stringifySpy.mock.calls.length; + const validateCallsAtRejection = validateMock.mock.calls.length; + stringifySpy.mockRestore(); + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe('Invalid input.'); + expect(stringifyCallsAtRejection).toBe(0); + expect(validateCallsAtRejection).toBe(0); }); it('throws when a typed field has the wrong type', () => { diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index 7284908fbb..09ef69e0f2 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -267,6 +267,10 @@ export const TransactionParamsStruct = object({ value: optional(StrictHexStruct), }); +const ALLOWED_TRANSACTION_PARAM_KEYS = new Set( + Object.keys(TransactionParamsStruct.schema as Record), +); + export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 128 * 1024; /** @@ -274,24 +278,49 @@ export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 128 * 1024; * standard transaction schema and rejects payloads whose serialized size * exceeds `MAX_TRANSACTION_PARAMS_SIZE_BYTES`. * - * Guards against two attack shapes: - * 1. Structural: extraneous top-level keys or ill-typed nested values - * (e.g. `{ from, to, data, test: { b: { b: ... } } }`) that would crash - * downstream normalization / PPOM WASM with `RangeError: Maximum call - * stack size exceeded`, silently bypassing security checks. - * 2. Size: valid-shaped but oversized payloads (e.g. `data` padded with - * millions of hex zeros, or `accessList` with millions of entries) that - * exhaust memory / stack in the same downstream code. + * Checks run in this order to guarantee we never recurse into hostile + * subtrees: + * + * 1. Top-level shape: params must be a plain object whose top-level keys + * are all in the schema. Runs in O(top-level-keys) without visiting + * nested values, so a deeply-nested subtree under an extraneous key + * (e.g. `{ from, to, test: { b: { b: ... × 1200 } } }`) is rejected + * before any recursive walk can `RangeError`. + * 2. Serialized size: `JSON.stringify(params).length` must be + * `<= MAX_TRANSACTION_PARAMS_SIZE_BYTES`. Safe to walk at this point + * because step 1 guarantees the only nested values live under + * schema-declared fields (`accessList`, `authorizationList`) which are + * shallow arrays of flat objects. + * 3. Full schema validation: per-field types (hex strings, addresses, + * `accessList` / `authorizationList` entry shapes). + * + * Together these guard against: + * - Structural attacks: extraneous top-level keys or ill-typed nested + * values that would crash downstream normalization / PPOM WASM with + * `RangeError: Maximum call stack size exceeded`, silently bypassing + * security checks. + * - Size attacks: valid-shaped but oversized payloads (e.g. `data` padded + * with millions of hex zeros) that exhaust memory / stack in the same + * downstream code. * * @param params - The transaction params object supplied by the dapp. - * @throws rpcErrors.invalidInput() if params does not match the schema or - * exceeds the size limit. + * @throws rpcErrors.invalidInput() if params is not a plain object, + * contains an extraneous top-level key, or exceeds the size limit. * @throws rpcErrors.invalidParams() with a Superstruct failure summary if * the schema mismatch is on a typed field. */ export function validateTransactionParams(params: unknown): void { - const serializedSize = JSON.stringify(params ?? null).length; - if (serializedSize > MAX_TRANSACTION_PARAMS_SIZE_BYTES) { + if (params === null || typeof params !== 'object' || Array.isArray(params)) { + throw rpcErrors.invalidInput(); + } + + for (const key of Object.keys(params)) { + if (!ALLOWED_TRANSACTION_PARAM_KEYS.has(key)) { + throw rpcErrors.invalidInput(); + } + } + + if (JSON.stringify(params).length > MAX_TRANSACTION_PARAMS_SIZE_BYTES) { throw rpcErrors.invalidInput(); } diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index ee4550add9..2e5eeb381b 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -171,7 +171,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow(/Invalid params/u); + ).rejects.toThrow('Invalid input.'); }); it('throws for the incident repro payload with deeply-nested junk', async () => { @@ -204,7 +204,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow(/Invalid params|Invalid input/u); + ).rejects.toThrow('Invalid input.'); }); it('should not override other request params', async () => { @@ -365,7 +365,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow(/Invalid params/u); + ).rejects.toThrow('Invalid input.'); }); it('throws for the incident repro payload with deeply-nested junk', async () => { @@ -398,7 +398,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow(/Invalid params|Invalid input/u); + ).rejects.toThrow('Invalid input.'); }); }); From a5db66797e4b7e59f30d40c0523a976f7d515772 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 14:14:24 +0300 Subject: [PATCH 07/17] Update --- packages/eth-json-rpc-middleware/src/utils/validation.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index 09ef69e0f2..17f6f199b1 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -271,7 +271,12 @@ const ALLOWED_TRANSACTION_PARAM_KEYS = new Set( Object.keys(TransactionParamsStruct.schema as Record), ); -export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 128 * 1024; +// Upper bound derived from the largest valid eth_sendTransaction payload: +// EIP-3860 caps initcode at 49,152 bytes → hex-encoded in 'data' field ≈ 98 KB of JSON. +// 200 KB is ~2× that ceiling, giving clear headroom above any protocol-legal +// transaction while blocking the padding attacks this cap defends against. +// TODO(CONF-1662): tighten once P99 production data is available. +export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 200 * 1024; /** * Validates `eth_sendTransaction` / `eth_signTransaction` params against the From fa3e7637bb174334b6e810d953d7c71e6cbfdda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20G=C3=B6ktu=C4=9F=20Poyraz?= Date: Tue, 14 Jul 2026 18:31:56 +0300 Subject: [PATCH 08/17] Update packages/eth-json-rpc-middleware/src/wallet.test.ts Co-authored-by: Elliot Winkler --- packages/eth-json-rpc-middleware/src/wallet.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index 2e5eeb381b..dd64ba1347 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -174,7 +174,7 @@ describe('wallet', () => { ).rejects.toThrow('Invalid input.'); }); - it('throws for the incident repro payload with deeply-nested junk', async () => { + it('throws when params contain deeply nested invalid data', async () => { const getAccounts = async (): Promise => testAddresses.slice(0, 2); const processTransaction = async (): Promise => testTxHash; From 77fd64e299caf06163257dee85c8313e4ec69adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20G=C3=B6ktu=C4=9F=20Poyraz?= Date: Tue, 14 Jul 2026 18:32:05 +0300 Subject: [PATCH 09/17] Update packages/eth-json-rpc-middleware/src/wallet.test.ts Co-authored-by: Elliot Winkler --- packages/eth-json-rpc-middleware/src/wallet.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index dd64ba1347..fd6be12f8d 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -368,7 +368,7 @@ describe('wallet', () => { ).rejects.toThrow('Invalid input.'); }); - it('throws for the incident repro payload with deeply-nested junk', async () => { + it('throws when params contain deeply nested invalid data', async () => { const getAccounts = async (): Promise => testAddresses.slice(0, 2); const processSignTransaction = async (): Promise => testTxHash; From d6b900cdf6ffc40d698c2029cbe3ce32f0153c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20G=C3=B6ktu=C4=9F=20Poyraz?= Date: Tue, 14 Jul 2026 18:32:48 +0300 Subject: [PATCH 10/17] Update packages/eth-json-rpc-middleware/src/utils/validation.test.ts Co-authored-by: Elliot Winkler --- packages/eth-json-rpc-middleware/src/utils/validation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts index caa86a8664..1c56939d92 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -353,7 +353,7 @@ describe('Validation Utils', () => { ).toThrow('Invalid input.'); }); - it('throws for the incident repro payload (deeply-nested junk field)', () => { + it('throws given deeply-nested invalid data', () => { let junk: Record = {}; for (let i = 0; i < 1200; i++) { junk = { b: junk }; From 2cdbbcc6b0c07877c2969e120d60918053fc6a0f Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 18:47:56 +0300 Subject: [PATCH 11/17] Changelog update --- packages/eth-json-rpc-middleware/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/eth-json-rpc-middleware/CHANGELOG.md b/packages/eth-json-rpc-middleware/CHANGELOG.md index 806b155806..8a7c2f19fb 100644 --- a/packages/eth-json-rpc-middleware/CHANGELOG.md +++ b/packages/eth-json-rpc-middleware/CHANGELOG.md @@ -9,7 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params — reject requests whose params do not match the transaction schema (extraneous top-level keys, ill-typed fields such as non-hex `to`/`data`, malformed `accessList` / `authorizationList` entries) or exceed `MAX_TRANSACTION_PARAMS_SIZE_BYTES` when serialized. Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields or padded payloads and silently bypassing security scans. ([#9482](https://github.com/MetaMask/core/pull/9482)) +- **BREAKING:** Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params ([#9482](https://github.com/MetaMask/core/pull/9482)) + - Reject requests whose params do not match the transaction schema (extraneous top-level keys, ill-typed fields such as non-hex `to`/`data`, malformed `accessList` / `authorizationList` entries) or exceed `MAX_TRANSACTION_PARAMS_SIZE_BYTES` when serialized + - Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields or padded payloads and silently bypassing security scans + - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.5.0` ([#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) - Bump `@metamask/message-manager` from `^14.1.1` to `^14.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755)) From 57e5c1c54ed3aca1d75c91563daf30bb7edf4337 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 18:51:08 +0300 Subject: [PATCH 12/17] Remove ALLOWED_TRANSACTION_PARAM_KEYS constant --- .../eth-json-rpc-middleware/src/utils/validation.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index 17f6f199b1..473958ed01 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -267,10 +267,6 @@ export const TransactionParamsStruct = object({ value: optional(StrictHexStruct), }); -const ALLOWED_TRANSACTION_PARAM_KEYS = new Set( - Object.keys(TransactionParamsStruct.schema as Record), -); - // Upper bound derived from the largest valid eth_sendTransaction payload: // EIP-3860 caps initcode at 49,152 bytes → hex-encoded in 'data' field ≈ 98 KB of JSON. // 200 KB is ~2× that ceiling, giving clear headroom above any protocol-legal @@ -319,15 +315,9 @@ export function validateTransactionParams(params: unknown): void { throw rpcErrors.invalidInput(); } - for (const key of Object.keys(params)) { - if (!ALLOWED_TRANSACTION_PARAM_KEYS.has(key)) { - throw rpcErrors.invalidInput(); - } - } + validateParams(params, TransactionParamsStruct); if (JSON.stringify(params).length > MAX_TRANSACTION_PARAMS_SIZE_BYTES) { throw rpcErrors.invalidInput(); } - - validateParams(params, TransactionParamsStruct); } From 30283aca45916cd5ff71b389f9d313a78d0d8aff Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 19:00:39 +0300 Subject: [PATCH 13/17] Fix tests --- .../src/utils/validation.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts index 1c56939d92..c3bcd5c7e1 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -350,10 +350,10 @@ describe('Validation Utils', () => { to: VALID_TO, extraKey: 'unexpected', }), - ).toThrow('Invalid input.'); + ).toThrow(/Invalid params/u); }); - it('throws given deeply-nested invalid data', () => { + it('throws when params contain an extraneous key with a deeply-nested value', () => { let junk: Record = {}; for (let i = 0; i < 1200; i++) { junk = { b: junk }; @@ -367,12 +367,11 @@ describe('Validation Utils', () => { data: '0x095ea7b3', test: junk, }), - ).toThrow('Invalid input.'); + ).toThrow(/Invalid params/u); }); - it('rejects an extraneous top-level key without walking its value (no JSON.stringify, no Superstruct)', () => { + it('rejects an extraneous top-level key without walking its value (JSON.stringify never called)', () => { const stringifySpy = jest.spyOn(JSON, 'stringify'); - validateMock.mockClear(); const params = { from: VALID_FROM, @@ -391,13 +390,10 @@ describe('Validation Utils', () => { thrown = error; } const stringifyCallsAtRejection = stringifySpy.mock.calls.length; - const validateCallsAtRejection = validateMock.mock.calls.length; stringifySpy.mockRestore(); expect(thrown).toBeInstanceOf(Error); - expect((thrown as Error).message).toBe('Invalid input.'); expect(stringifyCallsAtRejection).toBe(0); - expect(validateCallsAtRejection).toBe(0); }); it('throws when a typed field has the wrong type', () => { From 0fb9263ea8844ec391f3a5f896505358481db7e0 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 19:03:53 +0300 Subject: [PATCH 14/17] Update final --- packages/eth-json-rpc-middleware/src/utils/validation.ts | 2 +- packages/eth-json-rpc-middleware/src/wallet.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index 473958ed01..c6acc8e0d7 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -311,7 +311,7 @@ export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 200 * 1024; * the schema mismatch is on a typed field. */ export function validateTransactionParams(params: unknown): void { - if (params === null || typeof params !== 'object' || Array.isArray(params)) { + if (Array.isArray(params)) { throw rpcErrors.invalidInput(); } diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index fd6be12f8d..4055e6481f 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -171,7 +171,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow('Invalid input.'); + ).rejects.toThrow(/Invalid params/u); }); it('throws when params contain deeply nested invalid data', async () => { @@ -204,7 +204,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow('Invalid input.'); + ).rejects.toThrow(/Invalid params/u); }); it('should not override other request params', async () => { @@ -365,7 +365,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow('Invalid input.'); + ).rejects.toThrow(/Invalid params/u); }); it('throws when params contain deeply nested invalid data', async () => { @@ -398,7 +398,7 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow('Invalid input.'); + ).rejects.toThrow(/Invalid params/u); }); }); From 97c8b4b04979bbf80aebab1dc91015801519aa64 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 15 Jul 2026 14:13:38 +0300 Subject: [PATCH 15/17] Update JSDOC --- .../src/utils/validation.ts | 41 ++++++------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index c6acc8e0d7..8eca71a076 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -279,36 +279,21 @@ export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 200 * 1024; * standard transaction schema and rejects payloads whose serialized size * exceeds `MAX_TRANSACTION_PARAMS_SIZE_BYTES`. * - * Checks run in this order to guarantee we never recurse into hostile - * subtrees: - * - * 1. Top-level shape: params must be a plain object whose top-level keys - * are all in the schema. Runs in O(top-level-keys) without visiting - * nested values, so a deeply-nested subtree under an extraneous key - * (e.g. `{ from, to, test: { b: { b: ... × 1200 } } }`) is rejected - * before any recursive walk can `RangeError`. - * 2. Serialized size: `JSON.stringify(params).length` must be - * `<= MAX_TRANSACTION_PARAMS_SIZE_BYTES`. Safe to walk at this point - * because step 1 guarantees the only nested values live under - * schema-declared fields (`accessList`, `authorizationList`) which are - * shallow arrays of flat objects. - * 3. Full schema validation: per-field types (hex strings, addresses, - * `accessList` / `authorizationList` entry shapes). - * - * Together these guard against: - * - Structural attacks: extraneous top-level keys or ill-typed nested - * values that would crash downstream normalization / PPOM WASM with - * `RangeError: Maximum call stack size exceeded`, silently bypassing - * security checks. - * - Size attacks: valid-shaped but oversized payloads (e.g. `data` padded - * with millions of hex zeros) that exhaust memory / stack in the same - * downstream code. + * Guards against two attack shapes: + * - Structural: extraneous top-level keys or ill-typed fields (e.g. + * `{ from, to, test: { b: { b: ... × 1200 } } }`) that would crash + * downstream normalization / PPOM WASM with `RangeError: Maximum call + * stack size exceeded`, silently bypassing security checks. Superstruct's + * `object()` rejects unknown keys by name without accessing their values, + * so hostile nested subtrees are never traversed. + * - Size: valid-shaped but oversized payloads (e.g. `data` padded with + * millions of hex zeros) that exhaust memory in downstream code. * * @param params - The transaction params object supplied by the dapp. - * @throws rpcErrors.invalidInput() if params is not a plain object, - * contains an extraneous top-level key, or exceeds the size limit. - * @throws rpcErrors.invalidParams() with a Superstruct failure summary if - * the schema mismatch is on a typed field. + * @throws rpcErrors.invalidInput() if params is an array or exceeds the + * serialized size limit. + * @throws rpcErrors.invalidParams() if params fails schema validation + * (wrong type, extraneous top-level key, or malformed nested field). */ export function validateTransactionParams(params: unknown): void { if (Array.isArray(params)) { From 42f0c858181057ba36b6c880dc632accc7c4c481 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 15 Jul 2026 16:45:43 +0300 Subject: [PATCH 16/17] Remove array check --- .../src/utils/validation.test.ts | 4 +--- .../eth-json-rpc-middleware/src/utils/validation.ts | 12 ++++-------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts index c3bcd5c7e1..4b6a2f029f 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -338,9 +338,7 @@ describe('Validation Utils', () => { ['a boolean', true], ['an array', [{ from: VALID_FROM }]], ])('throws when params is %s', (_label, value) => { - expect(() => validateTransactionParams(value)).toThrow( - /Invalid params|Invalid input/u, - ); + expect(() => validateTransactionParams(value)).toThrow(/Invalid params/u); }); it('throws for an extraneous top-level key', () => { diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index 8eca71a076..6336a2d136 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -3,7 +3,7 @@ import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { Struct, StructError } from '@metamask/superstruct'; import { array, object, optional, validate } from '@metamask/superstruct'; import type { Hex } from '@metamask/utils'; -import { HexAddressStruct, StrictHexStruct } from '@metamask/utils'; +import { getJsonSize, HexAddressStruct, StrictHexStruct } from '@metamask/utils'; import type { WalletMiddlewareContext } from '../wallet'; import { parseTypedMessage } from './normalize'; @@ -290,19 +290,15 @@ export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 200 * 1024; * millions of hex zeros) that exhaust memory in downstream code. * * @param params - The transaction params object supplied by the dapp. - * @throws rpcErrors.invalidInput() if params is an array or exceeds the + * @throws rpcErrors.invalidParams() if params is an array or exceeds the * serialized size limit. - * @throws rpcErrors.invalidParams() if params fails schema validation + * @throws rpcErrors.invalidInput() if params fails schema validation * (wrong type, extraneous top-level key, or malformed nested field). */ export function validateTransactionParams(params: unknown): void { - if (Array.isArray(params)) { - throw rpcErrors.invalidInput(); - } - validateParams(params, TransactionParamsStruct); - if (JSON.stringify(params).length > MAX_TRANSACTION_PARAMS_SIZE_BYTES) { + if (getJsonSize(params) > MAX_TRANSACTION_PARAMS_SIZE_BYTES) { throw rpcErrors.invalidInput(); } } From ced4991c33ffe781f59c2665a7eb0e461ccc1d09 Mon Sep 17 00:00:00 2001 From: Ogulcan Poyraz Date: Wed, 15 Jul 2026 17:33:28 +0300 Subject: [PATCH 17/17] refactor: address PR review comments --- .../src/utils/validation.test.ts | 9 --- .../src/utils/validation.ts | 69 ++++++++++--------- .../src/wallet.test.ts | 8 ++- 3 files changed, 44 insertions(+), 42 deletions(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts index 4b6a2f029f..585162c93d 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -403,15 +403,6 @@ describe('Validation Utils', () => { ).toThrow(/Invalid params/u); }); - it('throws when `to` is not a hex address', () => { - expect(() => - validateTransactionParams({ - from: VALID_FROM, - to: 'not-an-address', - }), - ).toThrow(/Invalid params/u); - }); - it('throws when `data` is not a hex string', () => { expect(() => validateTransactionParams({ diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index 6336a2d136..96093c6923 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -1,9 +1,14 @@ import { TYPED_MESSAGE_SCHEMA } from '@metamask/eth-sig-util'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { Struct, StructError } from '@metamask/superstruct'; -import { array, object, optional, validate } from '@metamask/superstruct'; +import { + array, + object, + optional, + string, + validate, +} from '@metamask/superstruct'; import type { Hex } from '@metamask/utils'; -import { getJsonSize, HexAddressStruct, StrictHexStruct } from '@metamask/utils'; import type { WalletMiddlewareContext } from '../wallet'; import { parseTypedMessage } from './normalize'; @@ -236,35 +241,34 @@ export function validateTypedMessageKeys(data: string): void { } } -const AccessListEntryStruct = object({ - address: HexAddressStruct, - storageKeys: array(StrictHexStruct), -}); - -const AuthorizationListEntryStruct = object({ - address: HexAddressStruct, - chainId: StrictHexStruct, - nonce: StrictHexStruct, - r: optional(StrictHexStruct), - s: optional(StrictHexStruct), - yParity: optional(StrictHexStruct), -}); - export const TransactionParamsStruct = object({ - accessList: optional(array(AccessListEntryStruct)), - authorizationList: optional(array(AuthorizationListEntryStruct)), - chainId: optional(StrictHexStruct), - data: optional(StrictHexStruct), - from: HexAddressStruct, - gas: optional(StrictHexStruct), - gasLimit: optional(StrictHexStruct), - gasPrice: optional(StrictHexStruct), - maxFeePerGas: optional(StrictHexStruct), - maxPriorityFeePerGas: optional(StrictHexStruct), - nonce: optional(StrictHexStruct), - to: optional(HexAddressStruct), - type: optional(StrictHexStruct), - value: optional(StrictHexStruct), + accessList: optional( + array(object({ address: string(), storageKeys: array(string()) })), + ), + authorizationList: optional( + array( + object({ + address: string(), + chainId: string(), + nonce: string(), + r: optional(string()), + s: optional(string()), + yParity: optional(string()), + }), + ), + ), + chainId: optional(string()), + data: optional(string()), + from: string(), + gas: optional(string()), + gasLimit: optional(string()), + gasPrice: optional(string()), + maxFeePerGas: optional(string()), + maxPriorityFeePerGas: optional(string()), + nonce: optional(string()), + to: optional(string()), + type: optional(string()), + value: optional(string()), }); // Upper bound derived from the largest valid eth_sendTransaction payload: @@ -298,7 +302,10 @@ export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 200 * 1024; export function validateTransactionParams(params: unknown): void { validateParams(params, TransactionParamsStruct); - if (getJsonSize(params) > MAX_TRANSACTION_PARAMS_SIZE_BYTES) { + if ( + new TextEncoder().encode(JSON.stringify(params)).byteLength > + MAX_TRANSACTION_PARAMS_SIZE_BYTES + ) { throw rpcErrors.invalidInput(); } } diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts index 4055e6481f..a7eecb2481 100644 --- a/packages/eth-json-rpc-middleware/src/wallet.test.ts +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -116,7 +116,9 @@ describe('wallet', () => { method: 'eth_sendTransaction', params: [txParams], }); - await expect(engine.handle(payload)).rejects.toThrow(/Invalid params/u); + await expect(engine.handle(payload)).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); }); it('throws unauthorized for unknown addresses', async () => { @@ -313,7 +315,9 @@ describe('wallet', () => { await expect( engine.handle(...createHandleParams(payload)), - ).rejects.toThrow(/Invalid params/u); + ).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); }); it('should throw when provided unknown address', async () => {