Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/openapi-v2-files-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -3518,7 +3518,7 @@
"password": {
"description": "Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400.",
"type": "string",
"minLength": 1,
"minLength": 15,
"maxLength": 1024
},
"allowedEmails": {
Expand Down
12 changes: 11 additions & 1 deletion apps/sim/app/api/auth/forget-password/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing'
import { createMockRequest, requestUtilsMockFns, resetEnvMock, setEnv } from '@sim/testing'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
Expand Down Expand Up @@ -138,6 +138,16 @@ describe('Forget Password API Route', () => {
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
})

it('uses the recipient backstop when the client IP cannot be resolved', async () => {
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)

const response = await POST(createMockRequest('POST', { email: 'test@example.com' }))

expect(response.status).toBe(200)
expect(recipientKeys()).toHaveLength(1)
expect(mockRequestPasswordReset).toHaveBeenCalledOnce()
})

it('should reject external redirectTo URL', async () => {
const req = createMockRequest('POST', {
email: 'test@example.com',
Expand Down
7 changes: 5 additions & 2 deletions apps/sim/app/api/auth/forget-password/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { forgetPasswordContract } from '@/lib/api/contracts'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth } from '@/lib/auth'
import {
enforceIpRateLimit,
enforceIpRateLimitWithIndependentBackstop,
enforceRecipientRateLimit,
type TokenBucketConfig,
} from '@/lib/core/rate-limiter'
Expand All @@ -27,7 +27,10 @@ const RESET_EMAIL_RATE_LIMIT: TokenBucketConfig = {

export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const ipRateLimited = await enforceIpRateLimit('forget-password', request)
const ipRateLimited = await enforceIpRateLimitWithIndependentBackstop(
'forget-password',
request
)
if (ipRateLimited) return ipRateLimited

const parsed = await parseRequest(
Expand Down
60 changes: 53 additions & 7 deletions apps/sim/app/api/chat/[identifier]/otp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,48 @@ describe('Chat OTP API Route', () => {
})

it('returns 429 with Retry-After when email rate limit is exceeded', async () => {
mockCheckRateLimitDirect
.mockResolvedValueOnce({
allowed: true,
remaining: 9,
resetAt: new Date(Date.now() + 60_000),
})
.mockResolvedValueOnce({
allowed: true,
remaining: 99,
resetAt: new Date(Date.now() + 60_000),
})
.mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + 900_000),
retryAfterMs: 900_000,
})

const headerSet = vi.fn()
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
json: () => Promise.resolve({ error: message }),
status,
headers: { set: headerSet },
}))

queueDeployment(emailDeployment)

const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'POST',
body: JSON.stringify({ email: mockEmail }),
})

const response = await POST(request, {
params: Promise.resolve({ identifier: mockIdentifier }),
})

expect(response.status).toBe(429)
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
expect(mockSendEmail).not.toHaveBeenCalled()
})

it('returns 429 with Retry-After when the chat resource rate limit is exceeded', async () => {
mockCheckRateLimitDirect
.mockResolvedValueOnce({
allowed: true,
Expand Down Expand Up @@ -343,8 +385,8 @@ describe('Chat OTP API Route', () => {
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
})

it('folds spoofed `unknown` client IPs into a single shared bucket', async () => {
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown')
it('retains resource and email backstops when the client IP cannot be resolved', async () => {
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
queueDeployment(emailDeployment)

const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
Expand All @@ -355,13 +397,17 @@ describe('Chat OTP API Route', () => {
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })

expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2)
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
expect.stringMatching(/^chat-otp:ip:.*:unknown$/),
expect.any(Object)
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
1,
'chat-otp:resource:chat-123',
expect.any(Object),
{ failClosed: true }
)
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
2,
expect.stringContaining('chat-otp:email:'),
expect.any(Object)
expect.any(Object),
{ failClosed: true }
)
})
})
Expand Down
47 changes: 35 additions & 12 deletions apps/sim/app/api/chat/[identifier]/otp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MAX_OTP_ATTEMPTS,
OTP_EMAIL_RATE_LIMIT,
OTP_IP_RATE_LIMIT,
OTP_RESOURCE_RATE_LIMIT,
storeOTP,
} from '@/lib/core/security/otp'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
Expand All @@ -36,18 +37,21 @@ export const POST = withRouteHandler(

try {
const ip = getClientIp(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-otp:ip:${identifier}:${ip}`,
OTP_IP_RATE_LIMIT
)
if (!ipRateLimit.allowed) {
logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
const retryAfter = Math.ceil(
(ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000
if (ip) {
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-otp:ip:${identifier}:${ip}`,
OTP_IP_RATE_LIMIT,
{ failClosed: true }
)
const response = createErrorResponse('Too many requests. Please try again later.', 429)
response.headers.set('Retry-After', String(retryAfter))
return response
if (!ipRateLimit.allowed) {
logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
const retryAfter = Math.ceil(
(ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000
)
const response = createErrorResponse('Too many requests. Please try again later.', 429)
response.headers.set('Retry-After', String(retryAfter))
return response
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.
}

const parsed = await parseRequest(requestChatEmailOtpContract, request, context, {
Expand Down Expand Up @@ -85,13 +89,32 @@ export const POST = withRouteHandler(
? deployment.allowedEmails
: []

const resourceRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-otp:resource:${deployment.id}`,
OTP_RESOURCE_RATE_LIMIT,
{ failClosed: true }
)
if (!resourceRateLimit.allowed) {
logger.warn(`[${requestId}] OTP resource rate limit exceeded for chat ${deployment.id}`)
const retryAfter = Math.ceil(
(resourceRateLimit.retryAfterMs ?? OTP_RESOURCE_RATE_LIMIT.refillIntervalMs) / 1000
)
const response = createErrorResponse(
'Too many verification code requests. Please try again later.',
429
)
response.headers.set('Retry-After', String(retryAfter))
return response
}

if (!isEmailAllowed(email, allowedEmails)) {
return createErrorResponse('Email not authorized for this chat', 403)
}

const emailRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-otp:email:${deployment.id}:${email.toLowerCase()}`,
OTP_EMAIL_RATE_LIMIT
OTP_EMAIL_RATE_LIMIT,
{ failClosed: true }
)
if (!emailRateLimit.allowed) {
logger.warn(
Expand Down
92 changes: 92 additions & 0 deletions apps/sim/app/api/chat/[identifier]/sso/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* @vitest-environment node
*/
import { queueTableRows, requestUtilsMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockIsEmailAllowed, mockCheckRateLimitDirect } = vi.hoisted(() => ({
mockIsEmailAllowed: vi.fn(),
mockCheckRateLimitDirect: vi.fn(),
}))

vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: mockIsEmailAllowed }))
vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: class {
checkRateLimitDirect = mockCheckRateLimitDirect
},
}))

import { POST } from '@/app/api/chat/[identifier]/sso/route'

const deployment = {
id: 'chat-1',
authType: 'sso',
allowedEmails: ['@acme.com'],
isActive: true,
}

function post(email: string): NextRequest {
return new NextRequest('http://localhost/api/chat/support/sso', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email }),
})
}

const context = { params: Promise.resolve({ identifier: 'support' }) }

describe('POST /api/chat/[identifier]/sso', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
queueTableRows(schemaMock.chat, [deployment])
requestUtilsMockFns.mockGetClientIp.mockReturnValue('127.0.0.1')
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true })
mockIsEmailAllowed.mockReturnValue(true)
})

it('applies both client-IP and chat-resource limits', async () => {
const response = await POST(post('user@acme.com'), context)

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ eligible: true })
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
1,
'chat-sso:ip:127.0.0.1',
expect.objectContaining({ maxTokens: 20 }),
{ failClosed: true }
)
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
2,
'chat-sso:resource:chat-1',
expect.objectContaining({ maxTokens: 100 }),
{ failClosed: true }
)
})

it('returns 429 when the chat-resource limit is exceeded', async () => {
mockCheckRateLimitDirect
.mockResolvedValueOnce({ allowed: true })
.mockResolvedValueOnce({ allowed: false, retryAfterMs: 3000 })

const response = await POST(post('user@acme.com'), context)

expect(response.status).toBe(429)
expect(response.headers.get('Retry-After')).toBe('3')
})

it('retains the chat-resource limit when the client IP cannot be resolved', async () => {
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)

const response = await POST(post('user@acme.com'), context)

expect(response.status).toBe(200)
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1)
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
'chat-sso:resource:chat-1',
expect.objectContaining({ maxTokens: 100 }),
{ failClosed: true }
)
})
})
45 changes: 34 additions & 11 deletions apps/sim/app/api/chat/[identifier]/sso/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,33 @@ const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
refillIntervalMs: 15 * 60_000,
}

const SSO_RESOURCE_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 100,
refillRate: 100,
refillIntervalMs: 15 * 60_000,
}

function rateLimited(retryAfterMs: number | undefined, fallbackMs: number) {
const response = createErrorResponse('Too many requests. Please try again later.', 429)
response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000)))
return response
}

export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
const requestId = generateRequestId()

const ip = getClientIp(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-sso:ip:${ip}`,
SSO_IP_RATE_LIMIT
)
if (!ipRateLimit.allowed) {
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
const retryAfter = Math.ceil(
(ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000
if (ip) {
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-sso:ip:${ip}`,
SSO_IP_RATE_LIMIT,
{ failClosed: true }
)
const response = createErrorResponse('Too many requests. Please try again later.', 429)
response.headers.set('Retry-After', String(retryAfter))
return response
if (!ipRateLimit.allowed) {
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
return rateLimited(ipRateLimit.retryAfterMs, SSO_IP_RATE_LIMIT.refillIntervalMs)
}
}

const parsed = await parseRequest(chatSSOContract, request, context)
Expand All @@ -52,6 +62,7 @@ export const POST = withRouteHandler(

const [deployment] = await db
.select({
id: chat.id,
authType: chat.authType,
allowedEmails: chat.allowedEmails,
isActive: chat.isActive,
Expand All @@ -69,6 +80,18 @@ export const POST = withRouteHandler(
return createErrorResponse('Chat is not configured for SSO authentication', 400)
}

const resourceRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-sso:resource:${deployment.id}`,
SSO_RESOURCE_RATE_LIMIT,
{ failClosed: true }
)
if (!resourceRateLimit.allowed) {
logger.warn(`[${requestId}] SSO eligibility resource rate limit exceeded`, {
deploymentId: deployment.id,
})
return rateLimited(resourceRateLimit.retryAfterMs, SSO_RESOURCE_RATE_LIMIT.refillIntervalMs)
}

const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || [])

return createSuccessResponse({ eligible })
Expand Down
Loading
Loading