Skip to content

Commit f2dcb82

Browse files
committed
fix(security): address greptile feedback on OTP rate limiting
1 parent 07feab5 commit f2dcb82

2 files changed

Lines changed: 157 additions & 14 deletions

File tree

apps/sim/app/api/chat/[identifier]/otp/route.test.ts

Lines changed: 140 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,13 @@ vi.mock('@/lib/core/storage', () => ({
112112
getStorageMethod: mockGetStorageMethod,
113113
}))
114114

115+
const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
116+
mockCheckRateLimitDirect: vi.fn(),
117+
}))
118+
115119
vi.mock('@/lib/core/rate-limiter', () => ({
116120
RateLimiter: class {
117-
async checkRateLimitDirect() {
118-
return { allowed: true, remaining: 10, resetAt: new Date(Date.now() + 60_000) }
119-
}
121+
checkRateLimitDirect = mockCheckRateLimitDirect
120122
},
121123
}))
122124

@@ -242,6 +244,13 @@ describe('Chat OTP API Route', () => {
242244
}))
243245

244246
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-123')
247+
requestUtilsMockFns.mockGetClientIp.mockReturnValue('1.2.3.4')
248+
249+
mockCheckRateLimitDirect.mockResolvedValue({
250+
allowed: true,
251+
remaining: 10,
252+
resetAt: new Date(Date.now() + 60_000),
253+
})
245254

246255
mockZodParse.mockImplementation((data: unknown) => data)
247256

@@ -291,6 +300,134 @@ describe('Chat OTP API Route', () => {
291300
})
292301
})
293302

303+
describe('POST - Rate limiting', () => {
304+
const buildDeploymentSelect = () =>
305+
mockDbSelect.mockImplementationOnce(() => ({
306+
from: vi.fn().mockReturnValue({
307+
where: vi.fn().mockReturnValue({
308+
limit: vi.fn().mockResolvedValue([
309+
{
310+
id: mockChatId,
311+
authType: 'email',
312+
allowedEmails: [mockEmail],
313+
title: 'Test Chat',
314+
},
315+
]),
316+
}),
317+
}),
318+
}))
319+
320+
it('returns 429 with Retry-After when IP rate limit is exceeded', async () => {
321+
mockCheckRateLimitDirect.mockResolvedValueOnce({
322+
allowed: false,
323+
remaining: 0,
324+
resetAt: new Date(Date.now() + 900_000),
325+
retryAfterMs: 900_000,
326+
})
327+
328+
const headerSet = vi.fn()
329+
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
330+
json: () => Promise.resolve({ error: message }),
331+
status,
332+
headers: { set: headerSet },
333+
}))
334+
335+
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
336+
method: 'POST',
337+
body: JSON.stringify({ email: mockEmail }),
338+
})
339+
340+
const response = await POST(request, {
341+
params: Promise.resolve({ identifier: mockIdentifier }),
342+
})
343+
344+
expect(response.status).toBe(429)
345+
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
346+
expect(mockSendEmail).not.toHaveBeenCalled()
347+
expect(mockDbSelect).not.toHaveBeenCalled()
348+
})
349+
350+
it('returns 429 with Retry-After when email rate limit is exceeded', async () => {
351+
mockCheckRateLimitDirect
352+
.mockResolvedValueOnce({
353+
allowed: true,
354+
remaining: 9,
355+
resetAt: new Date(Date.now() + 60_000),
356+
})
357+
.mockResolvedValueOnce({
358+
allowed: false,
359+
remaining: 0,
360+
resetAt: new Date(Date.now() + 900_000),
361+
retryAfterMs: 900_000,
362+
})
363+
364+
const headerSet = vi.fn()
365+
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
366+
json: () => Promise.resolve({ error: message }),
367+
status,
368+
headers: { set: headerSet },
369+
}))
370+
371+
buildDeploymentSelect()
372+
373+
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
374+
method: 'POST',
375+
body: JSON.stringify({ email: mockEmail }),
376+
})
377+
378+
const response = await POST(request, {
379+
params: Promise.resolve({ identifier: mockIdentifier }),
380+
})
381+
382+
expect(response.status).toBe(429)
383+
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
384+
expect(mockSendEmail).not.toHaveBeenCalled()
385+
})
386+
387+
it('falls back to refill interval when retryAfterMs is missing', async () => {
388+
mockCheckRateLimitDirect.mockResolvedValueOnce({
389+
allowed: false,
390+
remaining: 0,
391+
resetAt: new Date(Date.now() + 900_000),
392+
})
393+
394+
const headerSet = vi.fn()
395+
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
396+
json: () => Promise.resolve({ error: message }),
397+
status,
398+
headers: { set: headerSet },
399+
}))
400+
401+
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
402+
method: 'POST',
403+
body: JSON.stringify({ email: mockEmail }),
404+
})
405+
406+
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
407+
408+
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
409+
})
410+
411+
it('skips IP rate limit when client IP is unknown', async () => {
412+
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown')
413+
buildDeploymentSelect()
414+
415+
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
416+
method: 'POST',
417+
body: JSON.stringify({ email: mockEmail }),
418+
})
419+
420+
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
421+
422+
// Only the email-scoped check should run, not the IP-scoped one
423+
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1)
424+
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
425+
expect.stringContaining('chat-otp:email:'),
426+
expect.any(Object)
427+
)
428+
})
429+
})
430+
294431
describe('POST - Store OTP (Database path)', () => {
295432
beforeEach(() => {
296433
mockGetStorageMethod.mockReturnValue('database')

apps/sim/app/api/chat/[identifier]/otp/route.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -231,16 +231,20 @@ export const POST = withRouteHandler(
231231

232232
try {
233233
const ip = getClientIp(request)
234-
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
235-
`chat-otp:ip:${identifier}:${ip}`,
236-
OTP_IP_RATE_LIMIT
237-
)
238-
if (!ipRateLimit.allowed) {
239-
logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
240-
const retryAfter = Math.ceil((ipRateLimit.retryAfterMs ?? 60_000) / 1000)
241-
const response = createErrorResponse('Too many requests. Please try again later.', 429)
242-
response.headers.set('Retry-After', String(retryAfter))
243-
return addCorsHeaders(response, request)
234+
if (ip !== 'unknown') {
235+
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
236+
`chat-otp:ip:${identifier}:${ip}`,
237+
OTP_IP_RATE_LIMIT
238+
)
239+
if (!ipRateLimit.allowed) {
240+
logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
241+
const retryAfter = Math.ceil(
242+
(ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000
243+
)
244+
const response = createErrorResponse('Too many requests. Please try again later.', 429)
245+
response.headers.set('Retry-After', String(retryAfter))
246+
return addCorsHeaders(response, request)
247+
}
244248
}
245249

246250
const body = await request.json()
@@ -292,7 +296,9 @@ export const POST = withRouteHandler(
292296
logger.warn(
293297
`[${requestId}] OTP email rate limit exceeded for ${email} on chat ${deployment.id}`
294298
)
295-
const retryAfter = Math.ceil((emailRateLimit.retryAfterMs ?? 60_000) / 1000)
299+
const retryAfter = Math.ceil(
300+
(emailRateLimit.retryAfterMs ?? OTP_EMAIL_RATE_LIMIT.refillIntervalMs) / 1000
301+
)
296302
const response = createErrorResponse(
297303
'Too many verification code requests. Please try again later.',
298304
429

0 commit comments

Comments
 (0)