Skip to content

Commit 4934bc8

Browse files
fix(crypto): bound public key batch lookups (#229)
1 parent ffda17b commit 4934bc8

2 files changed

Lines changed: 118 additions & 15 deletions

File tree

src/app/api/crypto/public-keys/route.js

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ function getServiceRoleClient() {
1717
// Create regular client for JWT validation
1818
const supabaseClient = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
1919

20+
export const MAX_PUBLIC_KEY_BATCH_SIZE = 100;
21+
22+
export function normalizePublicKeyUserIds(value) {
23+
if (!Array.isArray(value) || value.length > MAX_PUBLIC_KEY_BATCH_SIZE) {
24+
return null;
25+
}
26+
27+
const userIds = [];
28+
const seen = new Set();
29+
for (const candidate of value) {
30+
if (typeof candidate !== 'string') return null;
31+
const userId = candidate.trim();
32+
if (!userId || userId.length > 128) return null;
33+
if (seen.has(userId)) continue;
34+
seen.add(userId);
35+
userIds.push(userId);
36+
}
37+
38+
return userIds;
39+
}
40+
2041
/**
2142
* Authenticate user from request cookies
2243
* @param {Request} request
@@ -159,17 +180,26 @@ export async function POST(request) {
159180
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
160181
}
161182

162-
const { user_ids } = await request.json();
163-
164-
if (!user_ids || !Array.isArray(user_ids)) {
165-
return NextResponse.json({ error: 'Missing or invalid user_ids array' }, { status: 400 });
183+
let body;
184+
try {
185+
body = await request.json();
186+
} catch {
187+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
188+
}
189+
190+
const userIds = normalizePublicKeyUserIds(body?.user_ids);
191+
if (!userIds) {
192+
return NextResponse.json(
193+
{ error: `user_ids must contain at most ${MAX_PUBLIC_KEY_BATCH_SIZE} non-empty strings` },
194+
{ status: 400 }
195+
);
166196
}
167197

168-
/** @type {Record<string, string|null>} */
169-
const publicKeys = {};
198+
/** @type {Map<string, string|null>} */
199+
const publicKeys = new Map();
170200

171201
// Process each user ID
172-
for (const userId of user_ids) {
202+
for (const userId of userIds) {
173203
try {
174204
// Get the user's auth_user_id from the internal user ID
175205
const { data: userData, error: userError } = await getServiceRoleClient()
@@ -179,8 +209,8 @@ export async function POST(request) {
179209
.single();
180210

181211
if (userError || !userData?.auth_user_id) {
182-
console.log(`🔑 No auth_user_id found for internal user ${userId}`);
183-
publicKeys[userId] = null;
212+
console.log('No auth_user_id found for requested public key');
213+
publicKeys.set(userId, null);
184214
continue;
185215
}
186216

@@ -192,19 +222,19 @@ export async function POST(request) {
192222
});
193223

194224
if (error) {
195-
console.error(`Error fetching public key for user ${userId}:`, error);
196-
publicKeys[userId] = null;
225+
console.error('Error fetching requested public key:', error);
226+
publicKeys.set(userId, null);
197227
} else {
198-
publicKeys[userId] = publicKey;
228+
publicKeys.set(userId, publicKey);
199229
}
200230

201231
} catch (error) {
202-
console.error(`Error processing user ${userId}:`, error);
203-
publicKeys[userId] = null;
232+
console.error('Error processing public key lookup:', error);
233+
publicKeys.set(userId, null);
204234
}
205235
}
206236

207-
return NextResponse.json({ public_keys: publicKeys });
237+
return NextResponse.json({ public_keys: Object.fromEntries(publicKeys) });
208238

209239
} catch (error) {
210240
console.error('Error in POST /api/crypto/public-keys:', error);

src/app/api/crypto/public-keys/route.test.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,77 @@ describe('public key cookie authentication', () => {
121121
expect(body).toEqual({ error: 'Missing public_key' });
122122
expect(mocks.rpc).not.toHaveBeenCalled();
123123
});
124+
125+
it('rejects oversized public key batches before lookup work', async () => {
126+
const { POST } = await import('./route.js');
127+
const response = await POST(
128+
new Request('https://qrypt.chat/api/crypto/public-keys', {
129+
method: 'POST',
130+
headers: {
131+
cookie: `sb-xydzwxwsbgmznthiiscl-auth-token=${cookieValue('access-token')}`,
132+
'content-type': 'application/json'
133+
},
134+
body: JSON.stringify({
135+
user_ids: Array.from({ length: 101 }, (_, index) => `user-${index}`)
136+
})
137+
})
138+
);
139+
const body = await response.json();
140+
141+
expect(response.status).toBe(400);
142+
expect(body.error).toContain('at most 100');
143+
expect(mocks.serviceFrom).toHaveBeenCalledTimes(1);
144+
expect(mocks.rpc).not.toHaveBeenCalled();
145+
});
146+
147+
it('rejects malformed and invalid public key batches', async () => {
148+
const { POST } = await import('./route.js');
149+
const headers = {
150+
cookie: `sb-xydzwxwsbgmznthiiscl-auth-token=${cookieValue('access-token')}`,
151+
'content-type': 'application/json'
152+
};
153+
const malformedResponse = await POST(
154+
new Request('https://qrypt.chat/api/crypto/public-keys', {
155+
method: 'POST',
156+
headers,
157+
body: '{"user_ids":'
158+
})
159+
);
160+
const invalidResponse = await POST(
161+
new Request('https://qrypt.chat/api/crypto/public-keys', {
162+
method: 'POST',
163+
headers,
164+
body: JSON.stringify({ user_ids: ['valid-id', 42] })
165+
})
166+
);
167+
168+
expect(malformedResponse.status).toBe(400);
169+
expect(invalidResponse.status).toBe(400);
170+
expect(mocks.rpc).not.toHaveBeenCalled();
171+
});
172+
173+
it('trims and deduplicates public key batch ids', async () => {
174+
const { POST } = await import('./route.js');
175+
const response = await POST(
176+
new Request('https://qrypt.chat/api/crypto/public-keys', {
177+
method: 'POST',
178+
headers: {
179+
cookie: `sb-xydzwxwsbgmznthiiscl-auth-token=${cookieValue('access-token')}`,
180+
'content-type': 'application/json'
181+
},
182+
body: JSON.stringify({ user_ids: [' user-one ', 'user-one', 'user-two'] })
183+
})
184+
);
185+
const body = await response.json();
186+
187+
expect(response.status).toBe(200);
188+
expect(body).toEqual({
189+
public_keys: {
190+
'user-one': 'public-key',
191+
'user-two': 'public-key'
192+
}
193+
});
194+
expect(mocks.serviceFrom).toHaveBeenCalledTimes(3);
195+
expect(mocks.rpc).toHaveBeenCalledTimes(2);
196+
});
124197
});

0 commit comments

Comments
 (0)