Skip to content

Commit 556e0fd

Browse files
committed
fix(security): rate limit chat OTP endpoint to prevent email bombing
1 parent 74946fb commit 556e0fd

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

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

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

115+
vi.mock('@/lib/core/rate-limiter', () => ({
116+
RateLimiter: class {
117+
async checkRateLimitDirect() {
118+
return { allowed: true, remaining: 10, resetAt: new Date(Date.now() + 60_000) }
119+
}
120+
},
121+
}))
122+
115123
vi.mock('@/lib/messaging/email/mailer', () => ({
116124
sendEmail: mockSendEmail,
117125
}))

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,32 @@ import type { NextRequest } from 'next/server'
88
import { z } from 'zod'
99
import { renderOTPEmail } from '@/components/emails'
1010
import { getRedisClient } from '@/lib/core/config/redis'
11+
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
12+
import { RateLimiter } from '@/lib/core/rate-limiter'
1113
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
1214
import { getStorageMethod } from '@/lib/core/storage'
13-
import { generateRequestId } from '@/lib/core/utils/request'
15+
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
1416
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1517
import { sendEmail } from '@/lib/messaging/email/mailer'
1618
import { setChatAuthCookie } from '@/app/api/chat/utils'
1719
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1820

1921
const logger = createLogger('ChatOtpAPI')
2022

23+
const rateLimiter = new RateLimiter()
24+
25+
const OTP_IP_RATE_LIMIT: TokenBucketConfig = {
26+
maxTokens: 10,
27+
refillRate: 10,
28+
refillIntervalMs: 15 * 60_000,
29+
}
30+
31+
const OTP_EMAIL_RATE_LIMIT: TokenBucketConfig = {
32+
maxTokens: 3,
33+
refillRate: 3,
34+
refillIntervalMs: 15 * 60_000,
35+
}
36+
2137
function generateOTP(): string {
2238
return randomInt(100000, 1000000).toString()
2339
}
@@ -214,6 +230,19 @@ export const POST = withRouteHandler(
214230
const requestId = generateRequestId()
215231

216232
try {
233+
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)
244+
}
245+
217246
const body = await request.json()
218247
const { email } = otpRequestSchema.parse(body)
219248

@@ -255,6 +284,23 @@ export const POST = withRouteHandler(
255284
)
256285
}
257286

287+
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
288+
`chat-otp:email:${deployment.id}:${email.toLowerCase()}`,
289+
OTP_EMAIL_RATE_LIMIT
290+
)
291+
if (!emailRateLimit.allowed) {
292+
logger.warn(
293+
`[${requestId}] OTP email rate limit exceeded for ${email} on chat ${deployment.id}`
294+
)
295+
const retryAfter = Math.ceil((emailRateLimit.retryAfterMs ?? 60_000) / 1000)
296+
const response = createErrorResponse(
297+
'Too many verification code requests. Please try again later.',
298+
429
299+
)
300+
response.headers.set('Retry-After', String(retryAfter))
301+
return addCorsHeaders(response, request)
302+
}
303+
258304
const otp = generateOTP()
259305
await storeOTP(email, deployment.id, otp)
260306

0 commit comments

Comments
 (0)