Skip to content

Commit 0329bea

Browse files
authored
Validate kernel rpc request (#160)
* Validate kernel rpc request * update jsdoc * rpc check fixes * update tests * cursor fix for prototype pollution * linter fixes * use extractZodError
1 parent 283252f commit 0329bea

File tree

6 files changed

+634
-12
lines changed

6 files changed

+634
-12
lines changed

packages/permissions-kernel-snap/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
"test": "jest"
4848
},
4949
"dependencies": {
50-
"@metamask/snaps-sdk": "8.1.0"
50+
"@metamask/snaps-sdk": "8.1.0",
51+
"zod": "3.25.76"
5152
},
5253
"devDependencies": {
5354
"@jest/globals": "29.7.0",

packages/permissions-kernel-snap/src/index.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { logger } from '@metamask/7715-permissions-shared/utils';
22
import {
3+
InvalidParamsError,
34
LimitExceededError,
45
MethodNotFoundError,
56
type Json,
@@ -10,6 +11,7 @@ import {
1011
import { createPermissionOfferRegistryManager } from './registryManager';
1112
import { createRpcHandler } from './rpc/rpcHandler';
1213
import { RpcMethod } from './rpc/rpcMethod';
14+
import { validateJsonRpcRequest } from './utils';
1315

1416
// set up dependencies
1517
const rpcHandler = createRpcHandler({
@@ -38,9 +40,9 @@ let activeProcessingLock: symbol | null = null;
3840
* @param args - The request handler args as object.
3941
* @param args.origin - The origin of the request, e.g., the website that
4042
* invoked the snap.
41-
* @param args.request - A validated JSON-RPC request object.
42-
* @returns The result of `snap_dialog`.
43-
* @throws If the request method is not valid for this snap.
43+
* @param args.request - A JSON-RPC request object that will be validated.
44+
* @returns The result of the RPC method execution (The 7715 permissions response from the permissions provider).
45+
* @throws If the request is invalid, method is not found, or processing fails.
4446
*/
4547
export const onRpcRequest: OnRpcRequestHandler = async ({
4648
origin,
@@ -64,22 +66,35 @@ export const onRpcRequest: OnRpcRequestHandler = async ({
6466
JSON.stringify(request, undefined, 2),
6567
);
6668

69+
// First check if the request is a valid object
70+
if (typeof request !== 'object' || request === null) {
71+
throw new InvalidParamsError('Request must be a valid JSON-RPC object');
72+
}
73+
74+
// Check if method exists first (for proper error codes)
75+
if (!request.method || typeof request.method !== 'string') {
76+
throw new InvalidParamsError('Request must have a valid method');
77+
}
78+
6779
// Use Object.prototype.hasOwnProperty.call() to prevent prototype pollution attacks
68-
// This ensures we only access methods that exist on boundRpcHandlers itself
6980
if (
7081
!Object.prototype.hasOwnProperty.call(boundRpcHandlers, request.method)
7182
) {
83+
logger.warn('Method not found in bound handlers:', request.method);
7284
throw new MethodNotFoundError(`Method ${request.method} not found.`);
7385
}
7486

87+
// Now validate the full JSON-RPC structure
88+
const validatedRequest = validateJsonRpcRequest(request);
89+
7590
// We know that the method exists, so we can cast to NonNullable
76-
const handler = boundRpcHandlers[request.method] as NonNullable<
91+
const handler = boundRpcHandlers[validatedRequest.method] as NonNullable<
7792
(typeof boundRpcHandlers)[string]
7893
>;
7994

8095
const result = await handler({
8196
siteOrigin: origin,
82-
params: request.params as JsonRpcParams,
97+
params: validatedRequest.params as JsonRpcParams,
8398
});
8499

85100
return result;

packages/permissions-kernel-snap/src/utils/validate.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,49 @@ import {
55
zPermissionsRequest,
66
zPermissionsResponse,
77
} from '@metamask/7715-permissions-shared/types';
8-
import { extractZodError } from '@metamask/7715-permissions-shared/utils';
8+
import {
9+
extractZodError,
10+
logger,
11+
} from '@metamask/7715-permissions-shared/utils';
912
import { InvalidParamsError } from '@metamask/snaps-sdk';
13+
import { z } from 'zod';
14+
15+
import { RpcMethod } from '../rpc/rpcMethod';
16+
17+
/**
18+
* Checks if an object contains prototype pollution keys.
19+
* Recursively validates nested objects and arrays to prevent prototype pollution
20+
* at any depth in the object structure.
21+
*
22+
* @param obj - The object to check.
23+
* @returns True if the object is safe (no prototype pollution keys).
24+
*/
25+
function isSafeObject(obj: unknown): boolean {
26+
if (typeof obj !== 'object' || obj === null) {
27+
return true;
28+
}
29+
30+
// Check for exact dangerous keys (not substrings)
31+
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
32+
33+
// Check if any dangerous keys exist as own properties
34+
const hasDangerousKey = dangerousKeys.some((dangerousKey) =>
35+
Object.prototype.hasOwnProperty.call(obj, dangerousKey),
36+
);
37+
38+
if (hasDangerousKey) {
39+
return false;
40+
}
41+
42+
// Recursively check nested objects and arrays
43+
for (const value of Object.values(obj)) {
44+
if (!isSafeObject(value)) {
45+
return false;
46+
}
47+
}
48+
49+
return true;
50+
}
1051

1152
/**
1253
* Safely parses the grant permissions request parameters, validating them using Zod schema.
@@ -55,3 +96,74 @@ export const parsePermissionsResponseParam = (
5596

5697
return validatePermissionsResponse.data;
5798
};
99+
100+
/**
101+
* Zod schema for validating JSON-RPC request structure
102+
*/
103+
export const zJsonRpcRequest = z.object({
104+
/**
105+
* The JSON-RPC version, must be "2.0"
106+
*/
107+
jsonrpc: z.literal('2.0'),
108+
109+
/**
110+
* The method name - must be a valid RPC method
111+
*/
112+
method: z.nativeEnum(RpcMethod),
113+
114+
/**
115+
* The parameters for the method - must be valid JsonRpcParams
116+
*/
117+
params: z
118+
.union([
119+
z.string(),
120+
z.number(),
121+
z.boolean(),
122+
z.null(),
123+
z.array(z.unknown()).refine((arr) => arr.every(isSafeObject), {
124+
message: 'Invalid key in array: potential prototype pollution attempt',
125+
}),
126+
z.record(z.unknown()).refine((obj) => isSafeObject(obj), {
127+
message: 'Invalid key: potential prototype pollution attempt',
128+
}),
129+
])
130+
.optional(),
131+
132+
/**
133+
* The request ID - must be a string or number
134+
*/
135+
id: z.union([z.string(), z.number()]).optional(),
136+
});
137+
138+
/**
139+
* Type for a validated JSON-RPC request
140+
*/
141+
export type ValidatedJsonRpcRequest = z.infer<typeof zJsonRpcRequest>;
142+
143+
/**
144+
* Validates that the request object is a proper JSON-RPC request
145+
* and that the method is supported by this snap.
146+
*
147+
* @param request - The request object to validate.
148+
* @returns The validated request object.
149+
* @throws InvalidParamsError if validation fails.
150+
*/
151+
export function validateJsonRpcRequest(
152+
request: unknown,
153+
): ValidatedJsonRpcRequest {
154+
// Validate the JSON-RPC structure using Zod
155+
const validationResult = zJsonRpcRequest.safeParse(request);
156+
157+
if (!validationResult.success) {
158+
const errorMessage = extractZodError(validationResult.error.errors);
159+
160+
logger.warn('Invalid JSON-RPC request structure:', {
161+
errors: errorMessage,
162+
request: JSON.stringify(request, null, 2),
163+
});
164+
165+
throw new InvalidParamsError(`Invalid JSON-RPC request: ${errorMessage}`);
166+
}
167+
168+
return validationResult.data;
169+
}

packages/permissions-kernel-snap/test/end-to-end/index.test.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,110 @@ describe('Kernel Snap', () => {
4747
});
4848
}
4949
});
50+
51+
it('validates JSON-RPC request structure', async () => {
52+
// Test missing jsonrpc field
53+
const response1 = await snapRequest({
54+
method: 'wallet_requestExecutionPermissions',
55+
});
56+
57+
expect(response1).toRespondWithError({
58+
code: -32602,
59+
message: expect.stringContaining('Failed type validation'),
60+
stack: expect.any(String),
61+
});
62+
63+
// Test invalid jsonrpc version
64+
const response2 = await snapRequest({
65+
jsonrpc: '1.0',
66+
method: 'wallet_requestExecutionPermissions',
67+
} as any);
68+
69+
expect(response2).toRespondWithError({
70+
code: -32602,
71+
data: expect.objectContaining({
72+
cause: null,
73+
method: 'snapRpc',
74+
params: expect.arrayContaining([
75+
expect.stringMatching(/^local:http:\/\/localhost:\d+$/),
76+
'onRpcRequest',
77+
'https://metamask.io',
78+
expect.objectContaining({
79+
id: 1,
80+
jsonrpc: '1.0',
81+
method: 'wallet_requestExecutionPermissions',
82+
}),
83+
]),
84+
}),
85+
message: expect.stringContaining('Invalid parameters for method "snapRpc": At path: 3.jsonrpc -- Expected the literal `"2.0"`, but received: "1.0"'),
86+
stack: expect.any(String),
87+
});
88+
});
89+
90+
it('validates request method against allowed methods', async () => {
91+
const response = await snapRequest({
92+
jsonrpc: '2.0',
93+
method: 'invalid_method',
94+
} as any);
95+
96+
expect(response).toRespondWithError({
97+
code: -32601,
98+
message: 'Method invalid_method not found.',
99+
stack: expect.any(String),
100+
});
101+
});
102+
103+
it('prevents prototype pollution in request params', async () => {
104+
const response = await snapRequest({
105+
jsonrpc: '2.0',
106+
method: 'wallet_requestExecutionPermissions',
107+
params: {
108+
'__proto__': 'malicious',
109+
normalKey: 'value',
110+
},
111+
} as any);
112+
113+
expect(response).toRespondWithError({
114+
code: -32602,
115+
message: expect.stringContaining('Failed type validation'),
116+
stack: expect.any(String),
117+
});
118+
});
119+
120+
it('prevents prototype pollution in nested request params', async () => {
121+
const response = await snapRequest({
122+
jsonrpc: '2.0',
123+
method: 'wallet_requestExecutionPermissions',
124+
params: {
125+
normalKey: {
126+
'__proto__': 'malicious',
127+
},
128+
},
129+
} as any);
130+
131+
expect(response).toRespondWithError({
132+
code: -32602,
133+
message: expect.stringContaining('Failed type validation'),
134+
stack: expect.any(String),
135+
});
136+
});
137+
138+
it('allows valid JSON-RPC request structure', async () => {
139+
const response = await snapRequest({
140+
jsonrpc: '2.0',
141+
method: 'wallet_requestExecutionPermissions',
142+
params: {
143+
test: 'value',
144+
},
145+
id: 1,
146+
} as any);
147+
148+
// The request should be processed (may fail later due to test setup, but not due to validation)
149+
expect(response).not.toRespondWithError({
150+
code: -32602,
151+
message: expect.stringContaining('Failed type validation'),
152+
});
153+
});
50154
});
51155

52156
describe('processing lock', () => {

0 commit comments

Comments
 (0)