Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/eth-json-rpc-middleware/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. ([#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))
Expand Down
1 change: 1 addition & 0 deletions packages/eth-json-rpc-middleware/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ describe('index module', () => {
"createWalletMiddleware": [Function],
"providerAsMiddleware": [Function],
"providerAsMiddlewareV2": [Function],
"validateTransactionParams": [Function],
}
`);
});
Expand Down
1 change: 1 addition & 0 deletions packages/eth-json-rpc-middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ export {
} from './methods/wallet-get-supported-execution-permissions';
export * from './providerAsMiddleware';
export * from './retryOnEmpty';
export { validateTransactionParams } from './utils/validation';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extension/mobile PPOM middleware can gate params before their own normalizeTransactionParams call (which is where the WASM crash actually originates in clients).

export * from './wallet';
125 changes: 125 additions & 0 deletions packages/eth-json-rpc-middleware/src/utils/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, unknown> = {};
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<string, unknown> = { 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();
});
});
});
98 changes: 98 additions & 0 deletions packages/eth-json-rpc-middleware/src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
'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<string, unknown>,
)) {
assertMaxDepth((value as Record<string, unknown>)[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);
}
Loading