From a24978db9a8b7232c5e9de2e7fb85d69ef72872c Mon Sep 17 00:00:00 2001 From: Ogulcan Poyraz Date: Mon, 13 Jul 2026 11:25:24 +0300 Subject: [PATCH 01/23] 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 a6823832a41..0ff955199b1 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 1760a9bf154..6784cd7cb23 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.js'; import { + MAX_TRANSACTION_PARAM_DEPTH, resemblesAddress, validateAndNormalizeKeyholder, validateParams, + validateTransactionParams, validateTypedMessageKeys, } from './validation.js'; @@ -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 031fc0a76ca..a2e77d60518 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 5cb61e36644..8d858064b56 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 { createHandleParams, createRequest } from '../test/util/helpers.js'; import type { @@ -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 3b8e6f17f71..2d1abf84e76 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.js'; 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 0c9d578500c5554f6fbbf88ea2940a2fb3e18021 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 13 Jul 2026 11:41:32 +0300 Subject: [PATCH 02/23] 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 f5dd72753cc..e11c3fdabd0 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 73a5dc9f51b..ad07be25357 100644 --- a/packages/eth-json-rpc-middleware/src/index.ts +++ b/packages/eth-json-rpc-middleware/src/index.ts @@ -34,5 +34,6 @@ export { GetSupportedExecutionPermissionsResultStruct, } from './methods/wallet-get-supported-execution-permissions.js'; export * from './providerAsMiddleware.js'; +export { validateTransactionParams } from './utils/validation.js'; export * from './retryOnEmpty.js'; export * from './wallet.js'; From 8371c84ed3fb07fce29536b78d840d585918576f Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 13 Jul 2026 11:45:28 +0300 Subject: [PATCH 03/23] 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 0ff955199b1..e8bf13969ba 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 63071961d4c29741691deb0f7db0a2190badab08 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 13 Jul 2026 11:58:52 +0300 Subject: [PATCH 04/23] 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 e8bf13969ba..3e2467f183e 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 35cfbb3ea24ae5f5881109d4d49b99b4e54d155b Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 13:58:27 +0300 Subject: [PATCH 05/23] 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 3e2467f183e..806b1558067 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 6784cd7cb23..67a3edf8834 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.js'; 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 a2e77d60518..bc56f5f0f7b 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.js'; import { parseTypedMessage } from './normalize.js'; @@ -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 8d858064b56..2e8c0d082a2 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 969b628ad3ad4535e83322b7b23dc2e0bf4f2b02 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 14:10:05 +0300 Subject: [PATCH 06/23] 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 67a3edf8834..a463179c1e4 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 bc56f5f0f7b..05658fe4f68 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 2e8c0d082a2..811254477fe 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 25a792e8682e56d6b663a5e5e102ee7a380e38d6 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 14:14:24 +0300 Subject: [PATCH 07/23] 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 05658fe4f68..c5c5e784b07 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 d8741458ccbe45dff87cfc1b49809b6785732ce9 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/23] 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 811254477fe..3dcd4c489ae 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 2b7e306e0eed45dff4ba15131a489a2e3601bf1e 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/23] 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 3dcd4c489ae..de3381ea06a 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 c8591345d46a0e29f69915dd8ab86c57eb70deb2 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/23] 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 a463179c1e4..49a0178d6d9 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 61aeef48d8909a985b8478f9bc0635f3a0da9bfa Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 18:47:56 +0300 Subject: [PATCH 11/23] 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 806b1558067..8a7c2f19fb0 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 eae0c21c71fd6acf0df0ee51d63a6ee7e4b44a7d Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 18:51:08 +0300 Subject: [PATCH 12/23] 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 c5c5e784b07..cb124431bc0 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 157e36e79998a46f713622f178697420193ddbdc Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 19:00:39 +0300 Subject: [PATCH 13/23] 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 49a0178d6d9..063c2f036a7 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 82e84ca3f166bdf6381f6b9baddb609b9aecafdf Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 19:03:53 +0300 Subject: [PATCH 14/23] 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 cb124431bc0..e6fcaf08900 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 de3381ea06a..0b41854e1a1 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 0dce32fb1f8257134fb13894d8b4f94306c528a0 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 15 Jul 2026 14:13:38 +0300 Subject: [PATCH 15/23] 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 e6fcaf08900..5992a9a3501 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 0a9c9a7a750744bca45a8ecc7b151f194d7f576b Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 15 Jul 2026 16:45:43 +0300 Subject: [PATCH 16/23] 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 063c2f036a7..2a5aad7f87e 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 5992a9a3501..8b7029c501d 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.js'; import { parseTypedMessage } from './normalize.js'; @@ -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 acd7ff7ec437e033c343abaa389c4d5fd2416d32 Mon Sep 17 00:00:00 2001 From: Ogulcan Poyraz Date: Wed, 15 Jul 2026 17:33:28 +0300 Subject: [PATCH 17/23] 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 2a5aad7f87e..ea44a92a2c8 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 8b7029c501d..7e08bc482f9 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.js'; import { parseTypedMessage } from './normalize.js'; @@ -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 0b41854e1a1..26240fc50cb 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 () => { From 12c265afc6e6735e6cb1a7f810ad60f00c4ae621 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 20 Jul 2026 10:18:31 +0300 Subject: [PATCH 18/23] Fix tests --- .../src/utils/validation.test.ts | 32 +++++++------------ .../src/utils/validation.ts | 12 ++++--- 2 files changed, 19 insertions(+), 25 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 ea44a92a2c8..df580f24538 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -368,30 +368,22 @@ describe('Validation Utils', () => { ).toThrow(/Invalid params/u); }); - it('rejects an extraneous top-level key without walking its value (JSON.stringify never called)', () => { + it('runs the size check before schema validation', () => { const stringifySpy = jest.spyOn(JSON, 'stringify'); - 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; + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + extraKey: 'unexpected', + }), + ).toThrow(/Invalid params/u); + + expect(stringifySpy).toHaveBeenCalled(); + } finally { + stringifySpy.mockRestore(); } - const stringifyCallsAtRejection = stringifySpy.mock.calls.length; - stringifySpy.mockRestore(); - - expect(thrown).toBeInstanceOf(Error); - expect(stringifyCallsAtRejection).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 7e08bc482f9..f4222b16f90 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -284,14 +284,16 @@ export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 200 * 1024; * exceeds `MAX_TRANSACTION_PARAMS_SIZE_BYTES`. * * Guards against two attack shapes: + * - Size: valid-shaped but oversized payloads (e.g. `data` padded with + * millions of hex zeros) that exhaust memory in downstream code. Checked + * first via `JSON.stringify` so oversized input is rejected before schema + * work. * - 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. + * so hostile nested subtrees are never traversed by schema validation. * * @param params - The transaction params object supplied by the dapp. * @throws rpcErrors.invalidParams() if params is an array or exceeds the @@ -300,12 +302,12 @@ export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 200 * 1024; * (wrong type, extraneous top-level key, or malformed nested field). */ export function validateTransactionParams(params: unknown): void { - validateParams(params, TransactionParamsStruct); - if ( new TextEncoder().encode(JSON.stringify(params)).byteLength > MAX_TRANSACTION_PARAMS_SIZE_BYTES ) { throw rpcErrors.invalidInput(); } + + validateParams(params, TransactionParamsStruct); } From df3959fe4f32e1c3118e8c27e322016ee82ff84d Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 20 Jul 2026 10:22:25 +0300 Subject: [PATCH 19/23] Fix tests --- .../eth-json-rpc-middleware/src/utils/validation.test.ts | 3 +++ packages/eth-json-rpc-middleware/src/utils/validation.ts | 6 +++--- 2 files changed, 6 insertions(+), 3 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 df580f24538..2803f71b1b1 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -312,6 +312,9 @@ describe('Validation Utils', () => { chainId: '0x1', address: VALID_TO, nonce: '0x0', + r: '0x0', + s: '0x0', + yParity: '0x0', }, ], chainId: '0x1', diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index f4222b16f90..f567f7e0e33 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -251,9 +251,9 @@ export const TransactionParamsStruct = object({ address: string(), chainId: string(), nonce: string(), - r: optional(string()), - s: optional(string()), - yParity: optional(string()), + r: string(), + s: string(), + yParity: string(), }), ), ), From 740f11241f97301a54d191178baf2e6022264449 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 20 Jul 2026 10:29:55 +0300 Subject: [PATCH 20/23] Fix authrisation list --- .../eth-json-rpc-middleware/src/utils/validation.ts | 10 +++++----- 1 file 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 f567f7e0e33..4b2ea228e69 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -249,11 +249,11 @@ export const TransactionParamsStruct = object({ array( object({ address: string(), - chainId: string(), - nonce: string(), - r: string(), - s: string(), - yParity: string(), + chainId: optional(string()), + nonce: optional(string()), + r: optional(string()), + s: optional(string()), + yParity: optional(string()), }), ), ), From 593ce7ed66a83f0ab4863792907b6c91a04310b6 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 22 Jul 2026 13:45:36 +0300 Subject: [PATCH 21/23] Address comments --- .../eth-json-rpc-middleware/src/utils/validation.test.ts | 4 ++-- packages/eth-json-rpc-middleware/src/utils/validation.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 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 2803f71b1b1..2d4e343a4e1 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -425,7 +425,7 @@ describe('Validation Utils', () => { to: VALID_TO, data: padded, }), - ).toThrow('Invalid input.'); + ).toThrow('Request too large'); }); it('throws for an accessList-padding attack that passes the schema', () => { @@ -443,7 +443,7 @@ describe('Validation Utils', () => { to: VALID_TO, accessList: padded, }), - ).toThrow('Invalid input.'); + ).toThrow('Request too large'); }); it('does not throw for a legitimate multi-entry accessList well under the size limit', () => { diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index 4b2ea228e69..fdf41a34da6 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -3,9 +3,11 @@ import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { Struct, StructError } from '@metamask/superstruct'; import { array, + number, object, optional, string, + union, validate, } from '@metamask/superstruct'; import type { Hex } from '@metamask/utils'; @@ -260,7 +262,7 @@ export const TransactionParamsStruct = object({ chainId: optional(string()), data: optional(string()), from: string(), - gas: optional(string()), + gas: optional(union([string(), number()])), gasLimit: optional(string()), gasPrice: optional(string()), maxFeePerGas: optional(string()), @@ -306,7 +308,7 @@ export function validateTransactionParams(params: unknown): void { new TextEncoder().encode(JSON.stringify(params)).byteLength > MAX_TRANSACTION_PARAMS_SIZE_BYTES ) { - throw rpcErrors.invalidInput(); + throw rpcErrors.invalidInput('Request too large'); } validateParams(params, TransactionParamsStruct); From 81658c687fdd05ac84562fd87f45fd22613f95ea Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 31 Jul 2026 10:03:48 +0300 Subject: [PATCH 22/23] Add quantity struct --- .../src/utils/validation.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts index fdf41a34da6..a92d51d7ba6 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -243,6 +243,10 @@ export function validateTypedMessageKeys(data: string): void { } } +// Numerical fields accept both hex strings and numbers, as some dapps send +// numbers and `TransactionController` normalizes them downstream. +const QuantityStruct = union([string(), number()]); + export const TransactionParamsStruct = object({ accessList: optional( array(object({ address: string(), storageKeys: array(string()) })), @@ -262,15 +266,15 @@ export const TransactionParamsStruct = object({ chainId: optional(string()), data: optional(string()), from: string(), - gas: optional(union([string(), number()])), - gasLimit: optional(string()), - gasPrice: optional(string()), - maxFeePerGas: optional(string()), - maxPriorityFeePerGas: optional(string()), - nonce: optional(string()), + gas: optional(QuantityStruct), + gasLimit: optional(QuantityStruct), + gasPrice: optional(QuantityStruct), + maxFeePerGas: optional(QuantityStruct), + maxPriorityFeePerGas: optional(QuantityStruct), + nonce: optional(QuantityStruct), to: optional(string()), type: optional(string()), - value: optional(string()), + value: optional(QuantityStruct), }); // Upper bound derived from the largest valid eth_sendTransaction payload: From a4e603a9c171a1b62dbbddc491ec42d634808486 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 3 Aug 2026 13:28:28 +0300 Subject: [PATCH 23/23] Update error type --- packages/eth-json-rpc-middleware/src/utils/validation.ts | 2 +- 1 file changed, 1 insertion(+), 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 a92d51d7ba6..4c68d0ed804 100644 --- a/packages/eth-json-rpc-middleware/src/utils/validation.ts +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -312,7 +312,7 @@ export function validateTransactionParams(params: unknown): void { new TextEncoder().encode(JSON.stringify(params)).byteLength > MAX_TRANSACTION_PARAMS_SIZE_BYTES ) { - throw rpcErrors.invalidInput('Request too large'); + throw rpcErrors.invalidParams('Request too large'); } validateParams(params, TransactionParamsStruct);