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
4 changes: 4 additions & 0 deletions packages/eth-json-rpc-middleware/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **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))
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';
188 changes: 188 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_PARAMS_SIZE_BYTES,
resemblesAddress,
validateAndNormalizeKeyholder,
validateParams,
validateTransactionParams,
validateTypedMessageKeys,
} from './validation';

Expand Down Expand Up @@ -278,4 +280,190 @@ describe('Validation Utils', () => {
});
});
});

describe('validateTransactionParams', () => {
const VALID_FROM = '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb';
const VALID_TO = '0xdac17f958d2ee523a2206206994597c13d831ec7';

beforeEach(() => {
const actual = jest.requireActual<{
Comment thread
mcmire marked this conversation as resolved.
validate: typeof validate;
}>('@metamask/superstruct');
validateMock.mockImplementation(actual.validate);
});

it('does not throw for minimal valid params', () => {
expect(() =>
validateTransactionParams({ from: VALID_FROM }),
).not.toThrow();
});

it('does not throw for the full valid 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 params/u);
});

it('throws for an extraneous top-level key', () => {
expect(() =>
validateTransactionParams({
from: VALID_FROM,
to: VALID_TO,
extraKey: 'unexpected',
}),
).toThrow(/Invalid params/u);
});

it('throws when params contain an extraneous key with a deeply-nested value', () => {
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 params/u);
});

it('rejects an extraneous top-level key without walking its value (JSON.stringify never called)', () => {
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;
}
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', () => {
expect(() =>
validateTransactionParams({
from: VALID_FROM,
to: { nested: '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 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,
to: VALID_TO,
accessList: padded,
}),
).toThrow('Invalid input.');
});

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,
to: VALID_TO,
accessList: entries,
}),
).not.toThrow();
});
});
});
77 changes: 76 additions & 1 deletion packages/eth-json-rpc-middleware/src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
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,
string,
validate,
} from '@metamask/superstruct';
import type { Hex } from '@metamask/utils';

import type { WalletMiddlewareContext } from '../wallet';
Expand Down Expand Up @@ -234,3 +240,72 @@ export function validateTypedMessageKeys(data: string): void {
}
}
}

export const TransactionParamsStruct = object({
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()),
});
Comment thread
cursor[bot] marked this conversation as resolved.

// 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
* standard transaction schema and rejects payloads whose serialized size
* exceeds `MAX_TRANSACTION_PARAMS_SIZE_BYTES`.
*
* 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.invalidParams() if params is an array or exceeds the
* serialized size limit.
* @throws rpcErrors.invalidInput() if params fails schema validation
* (wrong type, extraneous top-level key, or malformed nested field).
*/
export function validateTransactionParams(params: unknown): void {
validateParams(params, TransactionParamsStruct);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Struct validation before size cap

Medium Severity

validateTransactionParams now runs full Superstruct validation before the JSON.stringify size check. Oversized but schema-shaped payloads (e.g. padded accessList entries) previously failed on length alone; they now pay for per-entry struct validation first, weakening the intended cheap early rejection.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3009741. Configure here.

Comment thread
cursor[bot] marked this conversation as resolved.

if (
new TextEncoder().encode(JSON.stringify(params)).byteLength >
MAX_TRANSACTION_PARAMS_SIZE_BYTES
) {
throw rpcErrors.invalidInput();
}
}
Loading
Loading