Skip to content

Commit e9029aa

Browse files
authored
Missing runtime schema verification for profile sync store/retrieve (#157)
* validate profile sync storage objects * manifest * add better check of zod validation * update mock auth position * use extractZodError
1 parent b65e862 commit e9029aa

File tree

3 files changed

+319
-57
lines changed

3 files changed

+319
-57
lines changed

packages/gator-permissions-snap/snap.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snap-7715-permissions.git"
88
},
99
"source": {
10-
"shasum": "Vs9lYGeC2uo7E6/p4eYFQG8i5gv0RJFqGEeuAxbV980=",
10+
"shasum": "DK0xWacmpY2VdHt48Tn5iw8Rpivla7dufa88PvDvPCw=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",

packages/gator-permissions-snap/src/profileSync/profileSync.ts

Lines changed: 130 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22
/* eslint-disable no-restricted-globals */
33
import type { PermissionResponse } from '@metamask/7715-permissions-shared/types';
4-
import { logger } from '@metamask/7715-permissions-shared/utils';
4+
import { zPermissionResponse } from '@metamask/7715-permissions-shared/types';
5+
import {
6+
logger,
7+
extractZodError,
8+
} from '@metamask/7715-permissions-shared/utils';
59
import {
610
hashDelegation,
711
decodeDelegations,
@@ -12,7 +16,78 @@ import type {
1216
JwtBearerAuth,
1317
UserStorage,
1418
} from '@metamask/profile-sync-controller/sdk';
15-
import { UnsupportedMethodError } from '@metamask/snaps-sdk';
19+
import {
20+
InvalidInputError,
21+
LimitExceededError,
22+
ParseError,
23+
UnsupportedMethodError,
24+
} from '@metamask/snaps-sdk';
25+
import { z } from 'zod';
26+
27+
// Constants for validation
28+
const MAX_STORAGE_SIZE_BYTES = 400 * 1024; // 400kb limit as documented
29+
30+
// Zod schema for runtime validation of StoredGrantedPermission
31+
const zStoredGrantedPermission = z.object({
32+
permissionResponse: zPermissionResponse,
33+
siteOrigin: z.string().min(1, 'Site origin cannot be empty'),
34+
});
35+
36+
/**
37+
* Safely deserializes and validates a JSON string as StoredGrantedPermission.
38+
* @param jsonString - The JSON string to deserialize.
39+
* @returns The validated StoredGrantedPermission object.
40+
* @throws InvalidInputError if validation fails.
41+
* @throws ParseError if parsing fails.
42+
*/
43+
function safeDeserializeStoredGrantedPermission(
44+
jsonString: string,
45+
): StoredGrantedPermission {
46+
try {
47+
const parsed = JSON.parse(jsonString);
48+
const validated = zStoredGrantedPermission.parse(parsed);
49+
return validated;
50+
} catch (error) {
51+
logger.error('Error deserializing stored granted permission:', error);
52+
if (error instanceof z.ZodError) {
53+
throw new InvalidInputError(extractZodError(error.errors));
54+
}
55+
throw new ParseError(`Failed to parse JSON`);
56+
}
57+
}
58+
59+
/**
60+
* Safely serializes a StoredGrantedPermission object with size validation.
61+
* @param permission - The permission object to serialize.
62+
* @returns The JSON string.
63+
* @throws LimitExceededError if size limit exceeded.
64+
*/
65+
function safeSerializeStoredGrantedPermission(
66+
permission: StoredGrantedPermission,
67+
): string {
68+
try {
69+
// Validate the object structure first
70+
const validated = zStoredGrantedPermission.parse(permission);
71+
72+
// Serialize to JSON
73+
const jsonString = JSON.stringify(validated);
74+
75+
// Check size limit
76+
const sizeBytes = new TextEncoder().encode(jsonString).length;
77+
if (sizeBytes > MAX_STORAGE_SIZE_BYTES) {
78+
throw new LimitExceededError(
79+
`Permission data exceeds size limit: ${sizeBytes} bytes > ${MAX_STORAGE_SIZE_BYTES} bytes`,
80+
);
81+
}
82+
83+
return jsonString;
84+
} catch (error) {
85+
if (error instanceof z.ZodError) {
86+
throw new InvalidInputError(extractZodError(error.errors));
87+
}
88+
throw error;
89+
}
90+
}
1691

1792
export type ProfileSyncManager = {
1893
getAllGrantedPermissions: () => Promise<StoredGrantedPermission[]>;
@@ -109,9 +184,22 @@ export function createProfileSyncManager(
109184
await authenticate();
110185

111186
const items = await userStorage.getAllFeatureItems(FEATURE);
112-
return (
113-
items?.map((item) => JSON.parse(item) as StoredGrantedPermission) ?? []
114-
);
187+
if (!items) {
188+
return [];
189+
}
190+
191+
const validPermissions: StoredGrantedPermission[] = [];
192+
193+
for (const item of items) {
194+
try {
195+
const permission = safeDeserializeStoredGrantedPermission(item);
196+
validPermissions.push(permission);
197+
} catch (error) {
198+
logger.warn('Skipping invalid permission data:', error);
199+
}
200+
}
201+
202+
return validPermissions;
115203
} catch (error) {
116204
logger.error('Error fetching all granted permissions:', error);
117205
throw error;
@@ -133,9 +221,11 @@ export function createProfileSyncManager(
133221
const path: UserStorageGenericPathWithFeatureAndKey = `${FEATURE}.${generateObjectKey(permissionContext)}`;
134222
const permission = await userStorage.getItem(path);
135223

136-
return permission
137-
? (JSON.parse(permission) as StoredGrantedPermission)
138-
: null;
224+
if (!permission) {
225+
return null;
226+
}
227+
228+
return safeDeserializeStoredGrantedPermission(permission);
139229
} catch (error) {
140230
logger.error('Error fetching granted permissions:', error);
141231
throw error;
@@ -147,7 +237,7 @@ export function createProfileSyncManager(
147237
*
148238
* Persisting "<permissionContext>" key under "gator_7715_permissions" feature
149239
* value has to be serialized to string and does not exceed 400kb
150-
* it is up to the SDK consumer to enforce proper schema management
240+
* Runtime schema validation is enforced to prevent corrupted data storage
151241
* will result in PUT /api/v1/userstorage/gator_7715_permissions/Hash(<storage_key+<permissionContext>">)
152242
* VALUE: encrypted("JSONstringifyPermission", storage_key).
153243
* @param storedGrantedPermission - The permission response to store.
@@ -158,8 +248,13 @@ export function createProfileSyncManager(
158248
try {
159249
await authenticate();
160250

251+
// Validate and serialize with size check
252+
const serializedPermission = safeSerializeStoredGrantedPermission(
253+
storedGrantedPermission,
254+
);
255+
161256
const path: UserStorageGenericPathWithFeatureAndKey = `${FEATURE}.${generateObjectKey(storedGrantedPermission.permissionResponse.context)}`;
162-
await userStorage.setItem(path, JSON.stringify(storedGrantedPermission));
257+
await userStorage.setItem(path, serializedPermission);
163258
} catch (error) {
164259
logger.error('Error storing granted permission:', error);
165260
throw error;
@@ -171,7 +266,7 @@ export function createProfileSyncManager(
171266
*
172267
* Batch set multiple items under the "gator_7715_permissions" feature
173268
* values have to be serialized to string and does not exceed 400kb
174-
* it is up to the SDK consumer to enforce proper schema management
269+
* Runtime schema validation is enforced to prevent corrupted data storage
175270
* will result in PUT /api/v1/userstorage/gator_7715_permissions/
176271
* VALUES: encrypted("JSONstringifyPermission1", storage_key), encrypted("JSONstringifyPermission2", storage_key).
177272
* @param storedGrantedPermissions - The permission responses to store.
@@ -182,15 +277,31 @@ export function createProfileSyncManager(
182277
try {
183278
await authenticate();
184279

185-
await userStorage.batchSetItems(
186-
FEATURE,
187-
storedGrantedPermissions.map((storedGrantedPermission) => [
188-
generateObjectKey(storedGrantedPermission.permissionResponse.context), // key
189-
JSON.stringify(storedGrantedPermission), // value
190-
]),
191-
);
280+
// Validate and serialize all permissions with size checks
281+
const validatedItems: [string, string][] = [];
282+
283+
for (const permission of storedGrantedPermissions) {
284+
try {
285+
const serializedPermission =
286+
safeSerializeStoredGrantedPermission(permission);
287+
validatedItems.push([
288+
generateObjectKey(permission.permissionResponse.context), // key
289+
serializedPermission, // value
290+
]);
291+
} catch (error) {
292+
logger.warn('Skipping invalid permission in batch:', error);
293+
}
294+
}
295+
296+
if (validatedItems.length === 0) {
297+
throw new InvalidInputError(
298+
'No valid permissions to store in batch operation',
299+
);
300+
}
301+
302+
await userStorage.batchSetItems(FEATURE, validatedItems);
192303
} catch (error) {
193-
logger.error('Error storing granted permission:', error);
304+
logger.error('Error storing granted permission batch:', error);
194305
throw error;
195306
}
196307
}

0 commit comments

Comments
 (0)