Skip to content

Commit 0543de6

Browse files
fix(security): isolate rejected OTP attempts
1 parent 33feaa1 commit 0543de6

4 files changed

Lines changed: 128 additions & 8 deletions

File tree

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,59 @@ describe('Chat OTP API Route', () => {
252252
})
253253

254254
describe('POST - Rate limiting', () => {
255+
it('isolates rejected emails from the OTP send bucket without a client IP', async () => {
256+
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
257+
queueDeployment(emailDeployment)
258+
259+
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
260+
method: 'POST',
261+
body: JSON.stringify({ email: 'not-allowed@example.com' }),
262+
})
263+
264+
const response = await POST(request, {
265+
params: Promise.resolve({ identifier: mockIdentifier }),
266+
})
267+
268+
expect(response.status).toBe(403)
269+
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1)
270+
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
271+
'chat-otp:rejected:chat-123',
272+
expect.any(Object),
273+
{ failClosed: true }
274+
)
275+
expect(mockSendEmail).not.toHaveBeenCalled()
276+
})
277+
278+
it('rate limits rejected emails independently without a client IP', async () => {
279+
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
280+
mockCheckRateLimitDirect.mockResolvedValueOnce({
281+
allowed: false,
282+
remaining: 0,
283+
resetAt: new Date(Date.now() + 900_000),
284+
retryAfterMs: 900_000,
285+
})
286+
const headerSet = vi.fn()
287+
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
288+
json: () => Promise.resolve({ error: message }),
289+
status,
290+
headers: { set: headerSet },
291+
}))
292+
queueDeployment(emailDeployment)
293+
294+
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
295+
method: 'POST',
296+
body: JSON.stringify({ email: 'not-allowed@example.com' }),
297+
})
298+
299+
const response = await POST(request, {
300+
params: Promise.resolve({ identifier: mockIdentifier }),
301+
})
302+
303+
expect(response.status).toBe(429)
304+
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
305+
expect(mockSendEmail).not.toHaveBeenCalled()
306+
})
307+
255308
it('returns 429 with Retry-After when IP rate limit is exceeded', async () => {
256309
mockCheckRateLimitDirect.mockResolvedValueOnce({
257310
allowed: false,

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,26 @@ export const POST = withRouteHandler(
8989
? deployment.allowedEmails
9090
: []
9191

92+
if (!isEmailAllowed(email, allowedEmails)) {
93+
const rejectedRateLimit = await rateLimiter.checkRateLimitDirect(
94+
`chat-otp:rejected:${deployment.id}`,
95+
OTP_RESOURCE_RATE_LIMIT,
96+
{ failClosed: true }
97+
)
98+
if (!rejectedRateLimit.allowed) {
99+
logger.warn(
100+
`[${requestId}] OTP rejected-email rate limit exceeded for chat ${deployment.id}`
101+
)
102+
const retryAfter = Math.ceil(
103+
(rejectedRateLimit.retryAfterMs ?? OTP_RESOURCE_RATE_LIMIT.refillIntervalMs) / 1000
104+
)
105+
const response = createErrorResponse('Too many requests. Please try again later.', 429)
106+
response.headers.set('Retry-After', String(retryAfter))
107+
return response
108+
}
109+
return createErrorResponse('Email not authorized for this chat', 403)
110+
}
111+
92112
const resourceRateLimit = await rateLimiter.checkRateLimitDirect(
93113
`chat-otp:resource:${deployment.id}`,
94114
OTP_RESOURCE_RATE_LIMIT,
@@ -107,10 +127,6 @@ export const POST = withRouteHandler(
107127
return response
108128
}
109129

110-
if (!isEmailAllowed(email, allowedEmails)) {
111-
return createErrorResponse('Email not authorized for this chat', 403)
112-
}
113-
114130
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
115131
`chat-otp:email:${deployment.id}:${email.toLowerCase()}`,
116132
OTP_EMAIL_RATE_LIMIT,

apps/sim/app/api/files/public/[token]/otp/route.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,46 @@ describe('POST /api/files/public/[token]/otp', () => {
106106
mockIsEmailAllowed.mockReturnValueOnce(false)
107107
const res = await POST(post('user@evil.com'), params())
108108
expect(res.status).toBe(403)
109+
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2)
110+
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
111+
2,
112+
'file-otp:rejected:sh_1',
113+
expect.any(Object),
114+
{ failClosed: true }
115+
)
109116
expect(mockStoreOTP).not.toHaveBeenCalled()
110117
})
111118

119+
it('isolates rejected emails from the OTP send bucket without a client IP', async () => {
120+
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
121+
mockIsEmailAllowed.mockReturnValueOnce(false)
122+
123+
const res = await POST(post('user@evil.com'), params())
124+
125+
expect(res.status).toBe(403)
126+
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1)
127+
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
128+
'file-otp:rejected:sh_1',
129+
expect.any(Object),
130+
{ failClosed: true }
131+
)
132+
expect(mockStoreOTP).not.toHaveBeenCalled()
133+
expect(mockSendEmail).not.toHaveBeenCalled()
134+
})
135+
136+
it('rate limits rejected emails independently without a client IP', async () => {
137+
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
138+
mockIsEmailAllowed.mockReturnValueOnce(false)
139+
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 })
140+
141+
const res = await POST(post('user@evil.com'), params())
142+
143+
expect(res.status).toBe(429)
144+
expect(res.headers.get('Retry-After')).toBe('1')
145+
expect(mockStoreOTP).not.toHaveBeenCalled()
146+
expect(mockSendEmail).not.toHaveBeenCalled()
147+
})
148+
112149
it('lowercases the email for allow-list matching and OTP storage', async () => {
113150
await POST(post('User@ACME.com'), params())
114151
expect(mockIsEmailAllowed).toHaveBeenCalledWith('user@acme.com', expect.anything())

apps/sim/app/api/files/public/[token]/otp/route.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ export const POST = withRouteHandler(
8989
)
9090
}
9191

92+
if (!isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails))) {
93+
const rejectedRateLimit = await rateLimiter.checkRateLimitDirect(
94+
`file-otp:rejected:${resolved.share.id}`,
95+
OTP_RESOURCE_RATE_LIMIT,
96+
{ failClosed: true }
97+
)
98+
if (!rejectedRateLimit.allowed) {
99+
logger.warn(
100+
`[${requestId}] OTP rejected-email rate limit exceeded for share ${resolved.share.id}`
101+
)
102+
return rateLimited(
103+
rejectedRateLimit.retryAfterMs,
104+
OTP_RESOURCE_RATE_LIMIT.refillIntervalMs
105+
)
106+
}
107+
return NextResponse.json({ error: 'Email not authorized for this file' }, { status: 403 })
108+
}
109+
92110
const resourceRateLimit = await rateLimiter.checkRateLimitDirect(
93111
`file-otp:resource:${resolved.share.id}`,
94112
OTP_RESOURCE_RATE_LIMIT,
@@ -101,10 +119,6 @@ export const POST = withRouteHandler(
101119
return rateLimited(resourceRateLimit.retryAfterMs, OTP_RESOURCE_RATE_LIMIT.refillIntervalMs)
102120
}
103121

104-
if (!isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails))) {
105-
return NextResponse.json({ error: 'Email not authorized for this file' }, { status: 403 })
106-
}
107-
108122
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
109123
`file-otp:email:${resolved.share.id}:${email}`,
110124
OTP_EMAIL_RATE_LIMIT,

0 commit comments

Comments
 (0)