mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
fix(security): harden public auth rate limits (#6997)
* fix(security): harden public auth rate limits * fix(security): fail closed without client IP * fix(security): backstop public OTP requests * fix(security): preserve independent rate-limit backstops
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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(() => ({
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
@@ -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', {
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(requestChatEmailOtpContract, request, context, {
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
@@ -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 })
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
*/
|
||||
import {
|
||||
authMockFns,
|
||||
createMockRequest,
|
||||
encryptionMock,
|
||||
encryptionMockFns,
|
||||
loggingSessionMock,
|
||||
requestUtilsMockFns,
|
||||
workflowsUtilsMock,
|
||||
} from '@sim/testing'
|
||||
import type { NextResponse } from 'next/server'
|
||||
@@ -208,6 +210,18 @@ describe('Chat API Utils', () => {
|
||||
|
||||
const result = await validateChatAuth('request-id', deployment, mockRequest, parsedBody)
|
||||
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'chat-password:ip:chat-id:127.0.0.1',
|
||||
expect.objectContaining({ maxTokens: 10 }),
|
||||
{ failClosed: true }
|
||||
)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'chat-password:resource:chat-id',
|
||||
expect.objectContaining({ maxTokens: 100 }),
|
||||
{ failClosed: true }
|
||||
)
|
||||
expect(decryptSecret).toHaveBeenCalledWith('encrypted-password')
|
||||
expect(result.authorized).toBe(true)
|
||||
})
|
||||
@@ -236,7 +250,7 @@ describe('Chat API Utils', () => {
|
||||
expect(result.error).toBe('Invalid password')
|
||||
})
|
||||
|
||||
it('should return 429 when the password attempt rate limit is exceeded', async () => {
|
||||
it('should return 429 when the password IP rate limit is exceeded', async () => {
|
||||
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 60_000 })
|
||||
|
||||
const deployment = {
|
||||
@@ -260,6 +274,63 @@ describe('Chat API Utils', () => {
|
||||
expect(result.status).toBe(429)
|
||||
expect(result.retryAfterMs).toBe(60_000)
|
||||
expect(decryptSecret).not.toHaveBeenCalled()
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
|
||||
'chat-password:ip:chat-id:127.0.0.1',
|
||||
expect.objectContaining({ maxTokens: 10 }),
|
||||
{ failClosed: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('should return 429 when the password resource rate limit is exceeded', async () => {
|
||||
mockCheckRateLimitDirect
|
||||
.mockResolvedValueOnce({ allowed: true })
|
||||
.mockResolvedValueOnce({ allowed: false, retryAfterMs: 30_000 })
|
||||
|
||||
const deployment = {
|
||||
id: 'chat-id',
|
||||
authType: 'password',
|
||||
password: 'encrypted-password',
|
||||
}
|
||||
const mockRequest = createMockRequest('POST')
|
||||
const candidate = 'password-attempt-fixture'
|
||||
|
||||
const result = await validateChatAuth('request-id', deployment, mockRequest, {
|
||||
password: candidate,
|
||||
})
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ authorized: false, status: 429, retryAfterMs: 30_000 })
|
||||
)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'chat-password:resource:chat-id',
|
||||
expect.objectContaining({ maxTokens: 100 }),
|
||||
{ failClosed: true }
|
||||
)
|
||||
expect(decryptSecret).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should retain the password resource limit when the client IP cannot be resolved', async () => {
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
|
||||
const deployment = {
|
||||
id: 'chat-id',
|
||||
authType: 'password',
|
||||
password: 'encrypted-password',
|
||||
}
|
||||
const mockRequest = createMockRequest('POST')
|
||||
const candidate = 'correct-password'
|
||||
|
||||
const result = await validateChatAuth('request-id', deployment, mockRequest, {
|
||||
password: candidate,
|
||||
})
|
||||
|
||||
expect(result.authorized).toBe(true)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
|
||||
'chat-password:resource:chat-id',
|
||||
expect.objectContaining({ maxTokens: 100 }),
|
||||
{ failClosed: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('should request email auth for email-protected chats', async () => {
|
||||
|
||||
@@ -54,8 +54,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
if (!ip) {
|
||||
logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`)
|
||||
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 })
|
||||
}
|
||||
const storageKey = `public:contact:${ip}`
|
||||
|
||||
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
|
||||
storageKey,
|
||||
PUBLIC_ENDPOINT_RATE_LIMIT
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/rate-limit', () => ({
|
||||
enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit,
|
||||
enforcePublicCredentialGroupOAuthStartIpRateLimit: mocks.ipRateLimit,
|
||||
enforceCredentialGroupEnrollmentOAuthRateLimit: mocks.enrollmentRateLimit,
|
||||
}))
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { startPublicCredentialGroupOAuth } from '@/lib/credential-groups/applica
|
||||
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
|
||||
import {
|
||||
enforceCredentialGroupEnrollmentOAuthRateLimit,
|
||||
enforcePublicCredentialGroupIpRateLimit,
|
||||
enforcePublicCredentialGroupOAuthStartIpRateLimit,
|
||||
} from '@/lib/credential-groups/rate-limit'
|
||||
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
|
||||
|
||||
@@ -24,7 +24,7 @@ export const GET = withRouteHandler(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ token: string; optionId: string }> }
|
||||
) => {
|
||||
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-start')
|
||||
const limited = await enforcePublicCredentialGroupOAuthStartIpRateLimit(request)
|
||||
|
||||
const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context)
|
||||
if (!parsed.success) return limited ?? parsed.response
|
||||
|
||||
@@ -28,8 +28,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
if (!ip) {
|
||||
logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
const storageKey = `public:demo-request:${ip}`
|
||||
|
||||
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
|
||||
storageKey,
|
||||
PUBLIC_ENDPOINT_RATE_LIMIT
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { requestUtilsMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -49,6 +50,7 @@ vi.mock('@/lib/core/security/otp', () => ({
|
||||
MAX_OTP_ATTEMPTS: 5,
|
||||
OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 },
|
||||
OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 },
|
||||
OTP_RESOURCE_RATE_LIMIT: { maxTokens: 100, refillRate: 100, refillIntervalMs: 1000 },
|
||||
}))
|
||||
vi.mock('@/components/emails', () => ({
|
||||
getOtpSubject: (label: string) => `Verification code for ${label}`,
|
||||
@@ -128,6 +130,40 @@ describe('POST /api/files/public/[token]/otp', () => {
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('1')
|
||||
})
|
||||
|
||||
it('returns 429 when the share resource rate limit is exceeded', async () => {
|
||||
mockCheckRateLimitDirect
|
||||
.mockResolvedValueOnce({ allowed: true })
|
||||
.mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 })
|
||||
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('1')
|
||||
expect(mockStoreOTP).not.toHaveBeenCalled()
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains resource and email backstops when the client IP cannot be resolved', async () => {
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
|
||||
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'file-otp:resource:sh_1',
|
||||
expect.any(Object),
|
||||
{ failClosed: true }
|
||||
)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'file-otp:email:sh_1:user@acme.com',
|
||||
expect.any(Object),
|
||||
{ failClosed: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/files/public/[token]/otp', () => {
|
||||
|
||||
@@ -19,6 +19,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'
|
||||
@@ -58,13 +59,16 @@ export const POST = withRouteHandler(
|
||||
|
||||
try {
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-otp:ip:${ip}`,
|
||||
OTP_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`)
|
||||
return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs)
|
||||
if (ip) {
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-otp:ip:${ip}`,
|
||||
OTP_IP_RATE_LIMIT,
|
||||
{ failClosed: true }
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`)
|
||||
return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(requestPublicFileOtpContract, request, context)
|
||||
@@ -85,13 +89,26 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
const resourceRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-otp:resource:${resolved.share.id}`,
|
||||
OTP_RESOURCE_RATE_LIMIT,
|
||||
{ failClosed: true }
|
||||
)
|
||||
if (!resourceRateLimit.allowed) {
|
||||
logger.warn(
|
||||
`[${requestId}] OTP resource rate limit exceeded for share ${resolved.share.id}`
|
||||
)
|
||||
return rateLimited(resourceRateLimit.retryAfterMs, OTP_RESOURCE_RATE_LIMIT.refillIntervalMs)
|
||||
}
|
||||
|
||||
if (!isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails))) {
|
||||
return NextResponse.json({ error: 'Email not authorized for this file' }, { status: 403 })
|
||||
}
|
||||
|
||||
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-otp:email:${resolved.share.id}:${email}`,
|
||||
OTP_EMAIL_RATE_LIMIT
|
||||
OTP_EMAIL_RATE_LIMIT,
|
||||
{ failClosed: true }
|
||||
)
|
||||
if (!emailRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { requestUtilsMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -49,6 +50,18 @@ describe('POST /api/files/public/[token]/sso', () => {
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ eligible: true })
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'file-sso:ip:127.0.0.1',
|
||||
expect.objectContaining({ maxTokens: 20 }),
|
||||
{ failClosed: true }
|
||||
)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'file-sso:resource:sh_1',
|
||||
expect.objectContaining({ maxTokens: 100 }),
|
||||
{ failClosed: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns eligible:false for a non-listed email', async () => {
|
||||
@@ -79,4 +92,29 @@ describe('POST /api/files/public/[token]/sso', () => {
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('2')
|
||||
})
|
||||
|
||||
it('returns 429 when the share resource limit is exceeded', async () => {
|
||||
mockCheckRateLimitDirect
|
||||
.mockResolvedValueOnce({ allowed: true })
|
||||
.mockResolvedValueOnce({ allowed: false, retryAfterMs: 3000 })
|
||||
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('3')
|
||||
})
|
||||
|
||||
it('uses the share resource limit when the client IP cannot be resolved', async () => {
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
|
||||
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
|
||||
'file-sso:resource:sh_1',
|
||||
expect.objectContaining({ maxTokens: 100 }),
|
||||
{ failClosed: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,21 @@ 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): NextResponse {
|
||||
const response = NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000)))
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/files/public/[token]/sso
|
||||
* Reports whether an email is on the allow-list for an SSO-gated share. The actual
|
||||
@@ -34,21 +49,16 @@ export const POST = withRouteHandler(
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-sso:ip:${ip}`,
|
||||
SSO_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
|
||||
const response = NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
if (ip) {
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-sso:ip:${ip}`,
|
||||
SSO_IP_RATE_LIMIT,
|
||||
{ failClosed: true }
|
||||
)
|
||||
response.headers.set(
|
||||
'Retry-After',
|
||||
String(Math.ceil((ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000))
|
||||
)
|
||||
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(publicFileSSOContract, request, context)
|
||||
@@ -64,6 +74,18 @@ export const POST = withRouteHandler(
|
||||
return NextResponse.json({ error: 'This file is not configured for SSO' }, { status: 400 })
|
||||
}
|
||||
|
||||
const resourceRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-sso:resource:${resolved.share.id}`,
|
||||
SSO_RESOURCE_RATE_LIMIT,
|
||||
{ failClosed: true }
|
||||
)
|
||||
if (!resourceRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] SSO eligibility resource rate limit exceeded`, {
|
||||
shareId: resolved.share.id,
|
||||
})
|
||||
return rateLimited(resourceRateLimit.retryAfterMs, SSO_RESOURCE_RATE_LIMIT.refillIntervalMs)
|
||||
}
|
||||
|
||||
const allowedEmails = Array.isArray(resolved.share.allowedEmails)
|
||||
? (resolved.share.allowedEmails as string[])
|
||||
: []
|
||||
|
||||
@@ -26,8 +26,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
if (!ip) {
|
||||
logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
const storageKey = `public:integration-request:${ip}`
|
||||
|
||||
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
|
||||
storageKey,
|
||||
PUBLIC_ENDPOINT_RATE_LIMIT
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('PATCH /api/v2/files/[fileId]/share', () => {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
isActive: true,
|
||||
authType: 'password',
|
||||
password: 'hunter2hunter2',
|
||||
password: 'hunter2hunter2!',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -222,7 +222,7 @@ describe('PATCH /api/v2/files/[fileId]/share', () => {
|
||||
assertedWorkspaceId: WORKSPACE_ID,
|
||||
isActive: true,
|
||||
authType: 'password',
|
||||
password: 'hunter2hunter2',
|
||||
password: 'hunter2hunter2!',
|
||||
allowedEmails: undefined,
|
||||
},
|
||||
request: expect.anything(),
|
||||
|
||||
@@ -102,10 +102,7 @@ export function sendToProfound(request: Request, statusCode: number): void {
|
||||
host: getBaseDomain(),
|
||||
path: url.pathname,
|
||||
status_code: statusCode,
|
||||
ip: (() => {
|
||||
const resolved = getClientIp(request)
|
||||
return resolved === 'unknown' ? '0.0.0.0' : resolved
|
||||
})(),
|
||||
ip: getClientIp(request) ?? '0.0.0.0',
|
||||
user_agent: request.headers.get('user-agent') || '',
|
||||
...(Object.keys(queryParams).length > 0 && { query_params: queryParams }),
|
||||
...(request.headers.get('referer') && { referer: request.headers.get('referer')! }),
|
||||
|
||||
@@ -28,8 +28,18 @@ describe('chat deployment password contract', () => {
|
||||
expect(result.error?.issues[0].message).toBe('Password cannot contain only whitespace')
|
||||
})
|
||||
|
||||
it('requires at least 15 characters for a new password', () => {
|
||||
const result = chatDeploymentPasswordSchema.safeParse('short-password')
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error?.issues[0].message).toBe('Password must be at least 15 characters')
|
||||
})
|
||||
|
||||
it('continues accepting legacy short passwords at the deployed login gate', () => {
|
||||
expect(deployedChatAuthBodySchema.safeParse({ password: 'legacy' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves surrounding whitespace, which login compares byte-exact', () => {
|
||||
expect(chatDeploymentPasswordSchema.parse(' hunter2 ')).toBe(' hunter2 ')
|
||||
expect(chatDeploymentPasswordSchema.parse(' hunter2hunter2 ')).toBe(' hunter2hunter2 ')
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -54,7 +64,9 @@ describe('chat deployment password contract', () => {
|
||||
|
||||
expect(createChatBodySchema.safeParse({ ...createBody, password: ' ' }).success).toBe(false)
|
||||
expect(createChatBodySchema.safeParse({ ...createBody, password: tooLong }).success).toBe(false)
|
||||
expect(createChatBodySchema.safeParse({ ...createBody, password: 'ok' }).success).toBe(true)
|
||||
expect(
|
||||
createChatBodySchema.safeParse({ ...createBody, password: 'correct-password' }).success
|
||||
).toBe(true)
|
||||
|
||||
expect(updateChatBodySchema.safeParse({ password: ' ' }).success).toBe(false)
|
||||
expect(updateChatBodySchema.safeParse({ password: tooLong }).success).toBe(false)
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ChatAuthType = z.output<typeof chatAuthTypeSchema>
|
||||
* would lock every visitor out of the deployment permanently.
|
||||
*/
|
||||
const MAX_CHAT_PASSWORD_CHARS = 1024
|
||||
const MIN_CHAT_PASSWORD_CHARS = 15
|
||||
|
||||
/**
|
||||
* Password accepted when setting or changing a chat deployment's password. The
|
||||
@@ -24,6 +25,10 @@ export const chatDeploymentPasswordSchema = z
|
||||
(password) => password.length === 0 || password.trim().length > 0,
|
||||
'Password cannot contain only whitespace'
|
||||
)
|
||||
.refine(
|
||||
(password) => password.length === 0 || password.length >= MIN_CHAT_PASSWORD_CHARS,
|
||||
`Password must be at least ${MIN_CHAT_PASSWORD_CHARS} characters`
|
||||
)
|
||||
|
||||
export const chatIdParamsSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
authenticatePublicFileBodySchema,
|
||||
sharePasswordSchema,
|
||||
upsertFileShareBodySchema,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { v2UpsertFileShareBodySchema } from '@/lib/api/contracts/v2/files'
|
||||
|
||||
describe('public file share password contracts', () => {
|
||||
it('requires 15 characters when a password is created or changed', () => {
|
||||
expect(sharePasswordSchema.safeParse('short-password').success).toBe(false)
|
||||
expect(sharePasswordSchema.safeParse('correct-password').success).toBe(true)
|
||||
expect(
|
||||
upsertFileShareBodySchema.safeParse({
|
||||
isActive: true,
|
||||
authType: 'password',
|
||||
password: 'short-password',
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
v2UpsertFileShareBodySchema.safeParse({
|
||||
workspaceId: 'workspace-1',
|
||||
isActive: true,
|
||||
authType: 'password',
|
||||
password: 'short-password',
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('continues accepting legacy short passwords at the public login gate', () => {
|
||||
expect(authenticatePublicFileBodySchema.safeParse({ password: 'legacy' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,12 @@ export type ShareAuthType = z.output<typeof shareAuthTypeSchema>
|
||||
/** An allowed email address or `@domain` pattern for email/SSO shares. */
|
||||
const allowedEmailSchema = z.string().min(1).max(320)
|
||||
|
||||
/** Password accepted when a file share is created or its password is changed. */
|
||||
export const sharePasswordSchema = z
|
||||
.string()
|
||||
.min(15, 'Password must be at least 15 characters')
|
||||
.max(1024, 'Password is too long')
|
||||
|
||||
/**
|
||||
* Public-safe representation of a `public_share` row. Never carries the
|
||||
* underlying storage key or the (encrypted) password — `hasPassword` is the
|
||||
@@ -42,11 +48,7 @@ const fileShareParamsSchema = z.object({
|
||||
export const upsertFileShareBodySchema = z.object({
|
||||
isActive: z.boolean(),
|
||||
authType: shareAuthTypeSchema.optional(),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password cannot be empty')
|
||||
.max(1024, 'Password is too long')
|
||||
.optional(),
|
||||
password: sharePasswordSchema.optional(),
|
||||
allowedEmails: z.array(allowedEmailSchema).max(200, 'Too many allowed emails').optional(),
|
||||
/** Client-reserved token persisted on first share. Ignored once the share row exists. */
|
||||
token: z
|
||||
@@ -149,7 +151,7 @@ export const getPublicInlineFileContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
const authenticatePublicFileBodySchema = z.object({
|
||||
export const authenticatePublicFileBodySchema = z.object({
|
||||
password: z.string().min(1, 'Password is required').max(1024, 'Password is too long'),
|
||||
})
|
||||
|
||||
@@ -162,8 +164,9 @@ const authenticatePublicFileResponseSchema = z.object({
|
||||
export type AuthenticatePublicFileResponse = z.output<typeof authenticatePublicFileResponseSchema>
|
||||
|
||||
/**
|
||||
* Exchanges a share password for a `file_auth_{shareId}` cookie. IP rate-limited;
|
||||
* returns 401 (`Invalid password`) on mismatch and 429 when throttled.
|
||||
* Exchanges a share password for a `file_auth_{shareId}` cookie. Client-IP and
|
||||
* share-resource rate-limited; returns 401 (`Invalid password`) on mismatch and
|
||||
* 429 when throttled.
|
||||
*/
|
||||
export const authenticatePublicFileContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
workspaceFileNameSchema,
|
||||
workspaceIdSchema,
|
||||
} from '@/lib/api/contracts/primitives'
|
||||
import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares'
|
||||
import {
|
||||
shareAuthTypeSchema,
|
||||
sharePasswordSchema,
|
||||
shareRecordSchema,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
import {
|
||||
V2_FALSE_VALUES,
|
||||
@@ -547,10 +551,7 @@ export const v2UpsertFileShareBodySchema = z
|
||||
.describe(
|
||||
'How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.'
|
||||
),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'password cannot be empty')
|
||||
.max(1024, 'password is too long')
|
||||
password: sharePasswordSchema
|
||||
.optional()
|
||||
.describe(
|
||||
'Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400.'
|
||||
|
||||
@@ -277,6 +277,16 @@ export const v2OrchestrationErrorPolicy = {
|
||||
|
||||
async function enforceV2PreAuthIpLimit(request: NextRequest): Promise<NextResponse | null> {
|
||||
const ip = getClientIp(request)
|
||||
if (!ip) {
|
||||
const resetAt = new Date(Date.now() + V2_PREAUTH_IP_LIMIT.refillIntervalMs)
|
||||
return v2RateLimitError({
|
||||
allowed: false,
|
||||
limit: V2_PREAUTH_IP_LIMIT.maxTokens,
|
||||
remaining: 0,
|
||||
resetAt,
|
||||
retryAfterMs: V2_PREAUTH_IP_LIMIT.refillIntervalMs,
|
||||
})
|
||||
}
|
||||
const abuseLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`v2:preauth:ip:${ip}`,
|
||||
V2_PREAUTH_IP_LIMIT,
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
isSsoEnabled,
|
||||
} from '@/lib/core/config/env-flags'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { trustedProxies } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
captureOAuthCredentialDraftBinding,
|
||||
@@ -165,17 +166,6 @@ if (validStripeKey) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-proxy hops trusted for forwarded-IP resolution. When configured,
|
||||
* Better Auth walks the x-forwarded-for chain right to left, skips these
|
||||
* hops, and records the first untrusted address as the session client IP —
|
||||
* preventing header spoofing behind multi-hop proxies.
|
||||
*/
|
||||
const trustedProxies = (env.AUTH_TRUSTED_PROXIES ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
/**
|
||||
* Resolves the org's API instance URL for a freshly linked Salesforce account.
|
||||
*
|
||||
|
||||
@@ -612,7 +612,8 @@ export const env = createEnv({
|
||||
REACT_SCAN_ENABLED: z.boolean().optional(), // Enable React Scan for performance debugging (dev only)
|
||||
|
||||
// Network / proxy trust
|
||||
AUTH_TRUSTED_PROXIES: z.string().optional(), // Comma-separated reverse-proxy IPs or CIDR ranges. When set, Better Auth walks the forwarded-IP chain right to left, skips these trusted hops, and uses the first untrusted address as the client IP. Leave unset to trust only single-value IP headers.
|
||||
/** Comma-separated proxy IPs/CIDRs skipped while resolving the forwarded client chain. */
|
||||
AUTH_TRUSTED_PROXIES: z.string().optional(),
|
||||
|
||||
// SSO Configuration (for script-based registration)
|
||||
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
|
||||
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
DEFAULT_PUBLIC_IP_ROUTE_LIMIT,
|
||||
DEFAULT_USER_ROUTE_LIMIT,
|
||||
enforceIpRateLimit,
|
||||
enforceIpRateLimitWithIndependentBackstop,
|
||||
enforceRecipientRateLimit,
|
||||
enforceUserOrIpRateLimit,
|
||||
enforceUserRateLimit,
|
||||
|
||||
@@ -22,16 +22,12 @@ vi.mock('@/lib/core/rate-limiter/storage', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function passThroughClientIp() {
|
||||
requestUtilsMockFns.mockGetClientIp.mockImplementation(
|
||||
(req: { headers: { get(name: string): string | null } }) =>
|
||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
req.headers.get('x-real-ip')?.trim() ||
|
||||
'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
import { enforceIpRateLimit, enforceUserOrIpRateLimit, enforceUserRateLimit } from './route-helpers'
|
||||
import {
|
||||
enforceIpRateLimit,
|
||||
enforceIpRateLimitWithIndependentBackstop,
|
||||
enforceUserOrIpRateLimit,
|
||||
enforceUserRateLimit,
|
||||
} from './route-helpers'
|
||||
|
||||
const consume = mockAdapter.consumeTokens as Mock
|
||||
|
||||
@@ -103,7 +99,7 @@ describe('route-helpers rate limiting', () => {
|
||||
|
||||
describe('enforceIpRateLimit', () => {
|
||||
beforeEach(() => {
|
||||
passThroughClientIp()
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValue('203.0.113.7')
|
||||
})
|
||||
|
||||
it('uses the X-Forwarded-For client IP in the bucket key', async () => {
|
||||
@@ -125,20 +121,25 @@ describe('route-helpers rate limiting', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('folds spoofed `X-Forwarded-For: unknown` into a single shared bucket', async () => {
|
||||
consume.mockResolvedValue({
|
||||
allowed: true,
|
||||
tokensRemaining: 9,
|
||||
resetAt: new Date(),
|
||||
})
|
||||
it('fails closed without creating a shared bucket when the client IP cannot be resolved', async () => {
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
|
||||
const request = createMockRequest('POST')
|
||||
|
||||
const reqA = createMockRequest('POST', undefined, { 'x-forwarded-for': 'unknown' })
|
||||
const reqB = createMockRequest('POST', undefined, { 'x-forwarded-for': 'unknown' })
|
||||
await enforceIpRateLimit('otp', reqA)
|
||||
await enforceIpRateLimit('otp', reqB)
|
||||
const result = await enforceIpRateLimit('otp', request)
|
||||
|
||||
const keys = consume.mock.calls.map((call) => call[0])
|
||||
expect(keys).toEqual(['route:otp:ip:unknown', 'route:otp:ip:unknown'])
|
||||
expect(result?.status).toBe(429)
|
||||
expect(result?.headers.get('Retry-After')).toBe('60')
|
||||
expect(consume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers an unresolved client only when the caller declares an independent backstop', async () => {
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
|
||||
const request = createMockRequest('POST')
|
||||
|
||||
const result = await enforceIpRateLimitWithIndependentBackstop('password-reset', request)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(consume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a 429 with Retry-After on rate limit', async () => {
|
||||
@@ -160,7 +161,7 @@ describe('route-helpers rate limiting', () => {
|
||||
|
||||
describe('enforceUserOrIpRateLimit', () => {
|
||||
beforeEach(() => {
|
||||
passThroughClientIp()
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValue('203.0.113.7')
|
||||
})
|
||||
|
||||
it('keys per-user when userId is present', async () => {
|
||||
|
||||
@@ -56,17 +56,22 @@ export async function enforceUserRateLimit(
|
||||
return buildRateLimitResponse(resetAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a per-IP token bucket to an unauthenticated route. The `unknown` IP
|
||||
* fallback shares one global bucket per route so it cannot be amplified by
|
||||
* `X-Forwarded-For: unknown` spoofing.
|
||||
*/
|
||||
export async function enforceIpRateLimit(
|
||||
async function enforceIpRateLimitWithPolicy(
|
||||
bucketName: string,
|
||||
request: NextRequest,
|
||||
config: TokenBucketConfig = DEFAULT_PUBLIC_IP_ROUTE_LIMIT
|
||||
config: TokenBucketConfig,
|
||||
unresolvedClientPolicy: 'deny' | 'defer'
|
||||
): Promise<NextResponse | null> {
|
||||
const ip = getClientIp(request)
|
||||
if (!ip) {
|
||||
logger.warn('Unable to resolve client IP for public rate limit', {
|
||||
bucket: bucketName,
|
||||
unresolvedClientPolicy,
|
||||
})
|
||||
return unresolvedClientPolicy === 'deny'
|
||||
? buildRateLimitResponse(new Date(Date.now() + config.refillIntervalMs))
|
||||
: null
|
||||
}
|
||||
const key = `route:${bucketName}:ip:${ip}`
|
||||
const { allowed, resetAt } = await rateLimiter.checkRateLimitDirect(key, config)
|
||||
if (allowed) return null
|
||||
@@ -74,6 +79,27 @@ export async function enforceIpRateLimit(
|
||||
return buildRateLimitResponse(resetAt)
|
||||
}
|
||||
|
||||
/** Apply a per-IP token bucket and fail closed when the client cannot be resolved safely. */
|
||||
export async function enforceIpRateLimit(
|
||||
bucketName: string,
|
||||
request: NextRequest,
|
||||
config: TokenBucketConfig = DEFAULT_PUBLIC_IP_ROUTE_LIMIT
|
||||
): Promise<NextResponse | null> {
|
||||
return enforceIpRateLimitWithPolicy(bucketName, request, config, 'deny')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a per-IP bucket when resolvable, deferring unresolved clients to an
|
||||
* independent non-IP limit that the caller must enforce before any side effect.
|
||||
*/
|
||||
export async function enforceIpRateLimitWithIndependentBackstop(
|
||||
bucketName: string,
|
||||
request: NextRequest,
|
||||
config: TokenBucketConfig = DEFAULT_PUBLIC_IP_ROUTE_LIMIT
|
||||
): Promise<NextResponse | null> {
|
||||
return enforceIpRateLimitWithPolicy(bucketName, request, config, 'defer')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a per-recipient token bucket to a route that mails an address the
|
||||
* caller chooses. A per-IP bucket cannot stop a distributed attempt to bomb one
|
||||
|
||||
@@ -26,6 +26,29 @@ const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps guesses against one resource independently of client identity. This is
|
||||
* the backstop for distributed attempts and for requests whose proxy chain
|
||||
* cannot be resolved safely.
|
||||
*/
|
||||
const PASSWORD_RESOURCE_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 100,
|
||||
refillRate: 100,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
function passwordRateLimitResult(
|
||||
retryAfterMs: number | undefined,
|
||||
fallbackMs: number
|
||||
): DeploymentAuthResult {
|
||||
return {
|
||||
authorized: false,
|
||||
error: 'Too many attempts. Please try again later.',
|
||||
status: 429,
|
||||
retryAfterMs: retryAfterMs ?? fallbackMs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A password/email-gated resource (a deployed chat or a public file share). Only
|
||||
* the fields the auth check needs — the `password` is the encrypted secret.
|
||||
@@ -106,22 +129,41 @@ export async function validateDeploymentAuth(
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`${cookiePrefix}-password:ip:${resource.id}:${ip}`,
|
||||
PASSWORD_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(
|
||||
`[${requestId}] Password attempt IP rate limit exceeded for ${resource.id} from ${ip}`
|
||||
if (ip) {
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`${cookiePrefix}-password:ip:${resource.id}:${ip}`,
|
||||
PASSWORD_IP_RATE_LIMIT,
|
||||
{ failClosed: true }
|
||||
)
|
||||
return {
|
||||
authorized: false,
|
||||
error: 'Too many attempts. Please try again later.',
|
||||
status: 429,
|
||||
retryAfterMs: ipRateLimit.retryAfterMs ?? PASSWORD_IP_RATE_LIMIT.refillIntervalMs,
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] Password attempt IP rate limit exceeded`, {
|
||||
resourceId: resource.id,
|
||||
cookiePrefix,
|
||||
ip,
|
||||
})
|
||||
return passwordRateLimitResult(
|
||||
ipRateLimit.retryAfterMs,
|
||||
PASSWORD_IP_RATE_LIMIT.refillIntervalMs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const resourceRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`${cookiePrefix}-password:resource:${resource.id}`,
|
||||
PASSWORD_RESOURCE_RATE_LIMIT,
|
||||
{ failClosed: true }
|
||||
)
|
||||
if (!resourceRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] Password attempt resource rate limit exceeded`, {
|
||||
resourceId: resource.id,
|
||||
cookiePrefix,
|
||||
})
|
||||
return passwordRateLimitResult(
|
||||
resourceRateLimit.retryAfterMs,
|
||||
PASSWORD_RESOURCE_RATE_LIMIT.refillIntervalMs
|
||||
)
|
||||
}
|
||||
|
||||
const { decrypted } = await decryptSecret(resource.password)
|
||||
if (!safeCompare(password, decrypted)) {
|
||||
return { authorized: false, error: 'Invalid password' }
|
||||
|
||||
@@ -28,6 +28,13 @@ export const OTP_EMAIL_RATE_LIMIT: TokenBucketConfig = {
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
/** Caps OTP requests against one deployment independently of client identity. */
|
||||
export const OTP_RESOURCE_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 100,
|
||||
refillRate: 100,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
/**
|
||||
* Key formats are kept per-kind to preserve any in-flight OTPs already issued
|
||||
* against existing chat deployments. The chat Redis key uses the legacy `otp:`
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function verifyTurnstileToken({
|
||||
const body = new URLSearchParams()
|
||||
body.set('secret', secret)
|
||||
body.set('response', token)
|
||||
if (remoteIp && remoteIp !== 'unknown') body.set('remoteip', remoteIp)
|
||||
if (remoteIp) body.set('remoteip', remoteIp)
|
||||
if (idempotencyKey) body.set('idempotency_key', idempotencyKey)
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { getRequestContext } from '@sim/logger'
|
||||
import { createClientIpResolver } from '@sim/security/ip'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const clientIpResolver = createClientIpResolver(env.AUTH_TRUSTED_PROXIES)
|
||||
|
||||
export const trustedProxies = clientIpResolver.trustedProxies
|
||||
|
||||
/**
|
||||
* Generate a short request ID for correlation. If called inside a request
|
||||
* context (see `withRouteHandler` and `runWithRequestContext`), returns the
|
||||
@@ -11,12 +18,13 @@ export function generateRequestId(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the client IP from a request, checking `x-forwarded-for` then `x-real-ip`.
|
||||
* Resolves the first untrusted client address from a proxy-appended forwarded
|
||||
* chain. Returns `null` when the chain is missing, malformed, or contains only
|
||||
* trusted proxies so callers can apply an explicit policy without sharing one
|
||||
* synthetic rate-limit identity.
|
||||
*/
|
||||
export function getClientIp(request: { headers: { get(name: string): string | null } }): string {
|
||||
return (
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip')?.trim() ||
|
||||
'unknown'
|
||||
)
|
||||
export function getClientIp(request: {
|
||||
headers: { get(name: string): string | null }
|
||||
}): string | null {
|
||||
return clientIpResolver.resolve(request.headers)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest, requestUtilsMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
|
||||
mockCheckRateLimitDirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/rate-limiter', () => ({
|
||||
RateLimitError: class extends Error {},
|
||||
RateLimiter: class {
|
||||
checkRateLimitDirect = mockCheckRateLimitDirect
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
enforcePublicCredentialGroupIpRateLimit,
|
||||
enforcePublicCredentialGroupOAuthStartIpRateLimit,
|
||||
} from '@/lib/credential-groups/rate-limit'
|
||||
|
||||
describe('public credential group rate limits', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails closed without a client IP when no independent backstop is declared', async () => {
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
|
||||
|
||||
const response = await enforcePublicCredentialGroupIpRateLimit(
|
||||
createMockRequest('GET'),
|
||||
'metadata'
|
||||
)
|
||||
|
||||
expect(response?.status).toBe(429)
|
||||
expect(mockCheckRateLimitDirect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers unresolved OAuth clients to the per-enrollment backstop', async () => {
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
|
||||
|
||||
const response = await enforcePublicCredentialGroupOAuthStartIpRateLimit(
|
||||
createMockRequest('GET')
|
||||
)
|
||||
|
||||
expect(response).toBeNull()
|
||||
expect(mockCheckRateLimitDirect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -58,13 +58,18 @@ function configForPublicScope(scope: PublicCredentialGroupRateLimitScope): Token
|
||||
return PUBLIC_OAUTH_CALLBACK_RATE_LIMIT
|
||||
}
|
||||
|
||||
/** Per-IP guard for unauthenticated enrollment reads and OAuth endpoints. */
|
||||
export async function enforcePublicCredentialGroupIpRateLimit(
|
||||
async function enforcePublicCredentialGroupIpRateLimitWithPolicy(
|
||||
request: { headers: { get(name: string): string | null } },
|
||||
scope: PublicCredentialGroupRateLimitScope
|
||||
scope: PublicCredentialGroupRateLimitScope,
|
||||
unresolvedClientPolicy: 'deny' | 'defer'
|
||||
): Promise<NextResponse | null> {
|
||||
const config = configForPublicScope(scope)
|
||||
const ip = getClientIp(request)
|
||||
if (!ip) {
|
||||
return unresolvedClientPolicy === 'deny'
|
||||
? rateLimitResponse(undefined, config.refillIntervalMs)
|
||||
: null
|
||||
}
|
||||
const result = await rateLimiter.checkRateLimitDirect(
|
||||
`public-credential-group:${scope}:ip:${ip}`,
|
||||
config,
|
||||
@@ -73,6 +78,24 @@ export async function enforcePublicCredentialGroupIpRateLimit(
|
||||
return result.allowed ? null : rateLimitResponse(result.retryAfterMs, config.refillIntervalMs)
|
||||
}
|
||||
|
||||
/** Per-IP guard for unauthenticated enrollment reads and OAuth endpoints. */
|
||||
export async function enforcePublicCredentialGroupIpRateLimit(
|
||||
request: { headers: { get(name: string): string | null } },
|
||||
scope: PublicCredentialGroupRateLimitScope
|
||||
): Promise<NextResponse | null> {
|
||||
return enforcePublicCredentialGroupIpRateLimitWithPolicy(request, scope, 'deny')
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the OAuth-start IP bucket when resolvable and otherwise defers to the
|
||||
* mandatory per-enrollment OAuth budget applied after invitation authentication.
|
||||
*/
|
||||
export async function enforcePublicCredentialGroupOAuthStartIpRateLimit(request: {
|
||||
headers: { get(name: string): string | null }
|
||||
}): Promise<NextResponse | null> {
|
||||
return enforcePublicCredentialGroupIpRateLimitWithPolicy(request, 'oauth-start', 'defer')
|
||||
}
|
||||
|
||||
/** Prevents one leaked invitation from starting unbounded provider consent flows. */
|
||||
export async function enforceCredentialGroupEnrollmentOAuthRateLimit(
|
||||
enrollmentId: string
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { requestUtilsMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
|
||||
mockCheckRateLimitDirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/rate-limiter', () => ({
|
||||
RateLimiter: class {
|
||||
checkRateLimitDirect = mockCheckRateLimitDirect
|
||||
},
|
||||
}))
|
||||
|
||||
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
|
||||
|
||||
describe('enforcePublicFileRateLimit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails closed without creating a shared bucket when the client IP cannot be resolved', async () => {
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
|
||||
|
||||
const response = await enforcePublicFileRateLimit(new Request('http://localhost'), 'content')
|
||||
|
||||
expect(response?.status).toBe(429)
|
||||
expect(response?.headers.get('Retry-After')).toBe('60')
|
||||
expect(mockCheckRateLimitDirect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -22,15 +22,21 @@ const CONTENT_RATE_LIMIT: TokenBucketConfig = {
|
||||
* Per-IP rate limit for the unauthenticated public share endpoints, returning a
|
||||
* `429` response when exceeded (or `null` to proceed). The token is unguessable,
|
||||
* so this defends a *known* link against hammering (DoS / S3 egress) rather than
|
||||
* enumeration. Fails open on storage errors (availability over strictness),
|
||||
* matching the chat public route.
|
||||
* enumeration. Fails open on storage errors (availability over strictness), but
|
||||
* fails closed when the forwarded chain cannot identify a safe client key.
|
||||
*/
|
||||
export async function enforcePublicFileRateLimit(
|
||||
request: { headers: { get(name: string): string | null } },
|
||||
scope: 'metadata' | 'content'
|
||||
): Promise<NextResponse | null> {
|
||||
const ip = getClientIp(request)
|
||||
const config = scope === 'content' ? CONTENT_RATE_LIMIT : METADATA_RATE_LIMIT
|
||||
const ip = getClientIp(request)
|
||||
if (!ip) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429, headers: { 'Retry-After': String(config.refillIntervalMs / 1000) } }
|
||||
)
|
||||
}
|
||||
const result = await rateLimiter.checkRateLimitDirect(`public-file:${scope}:${ip}`, config)
|
||||
if (result.allowed) return null
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { normalizeIpAddress } from '@sim/security/ip'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getClientIp } from '@/lib/core/utils/request'
|
||||
@@ -161,8 +162,11 @@ export const genericHandler: WebhookProviderHandler = {
|
||||
const allowedIps = providerConfig.allowedIps
|
||||
if (allowedIps && Array.isArray(allowedIps) && allowedIps.length > 0) {
|
||||
const clientIp = getClientIp(request)
|
||||
const clientIpAllowed = allowedIps.some(
|
||||
(allowedIp) => typeof allowedIp === 'string' && normalizeIpAddress(allowedIp) === clientIp
|
||||
)
|
||||
|
||||
if (clientIp === 'unknown' || !allowedIps.includes(clientIp)) {
|
||||
if (!clientIp || !clientIpAllowed) {
|
||||
logger.warn(`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`)
|
||||
return new NextResponse('Forbidden - IP not allowed', {
|
||||
status: 403,
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"dependencies": {
|
||||
"@sim/db": "workspace:*",
|
||||
"@sim/logger": "workspace:*",
|
||||
"@sim/security": "workspace:*",
|
||||
"@sim/utils": "workspace:*",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
},
|
||||
|
||||
@@ -23,10 +23,10 @@ services:
|
||||
# (apex + www, alias hostnames, reverse-proxy IPs). Empty by default.
|
||||
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-}
|
||||
# AUTH_TRUSTED_PROXIES: comma-separated reverse-proxy IPs or CIDR ranges in
|
||||
# front of the app (ingress, load balancer). Better Auth walks
|
||||
# x-forwarded-for right to left, skips these hops, and uses the first
|
||||
# untrusted address as the client IP. Required for correct session IPs and
|
||||
# rate-limit keying behind a multi-hop proxy chain. Empty by default.
|
||||
# front of the app (ingress, load balancer). Sim and Better Auth walk
|
||||
# x-forwarded-for right to left, skip these hops, and use the first
|
||||
# untrusted address as the client IP. Required to look through known
|
||||
# intermediate proxies in a multi-hop chain. Empty by default.
|
||||
- AUTH_TRUSTED_PROXIES=${AUTH_TRUSTED_PROXIES:-}
|
||||
# Required. Compose aborts with this message rather than starting the app
|
||||
# with an empty secret, which would silently corrupt stored credentials.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"dependencies": {
|
||||
"@sim/db": "workspace:*",
|
||||
"@sim/logger": "workspace:*",
|
||||
"@sim/security": "workspace:*",
|
||||
"@sim/utils": "workspace:*",
|
||||
"drizzle-orm": "^0.45.2"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
requestUtilsMockFns,
|
||||
resetDbChainMock,
|
||||
} from '@sim/testing'
|
||||
import { auditMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
@@ -81,12 +75,6 @@ describe('recordAudit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
requestUtilsMockFns.mockGetClientIp.mockImplementation(
|
||||
(request: { headers: { get(name: string): string | null } }) =>
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip')?.trim() ||
|
||||
'unknown'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -187,13 +175,13 @@ describe('recordAudit', () => {
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ipAddress: '1.2.3.4',
|
||||
ipAddress: '5.6.7.8',
|
||||
userAgent: 'TestAgent/1.0',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to x-real-ip when x-forwarded-for is absent', async () => {
|
||||
it('records null when x-forwarded-for is absent', async () => {
|
||||
const request = new Request('https://example.com', {
|
||||
headers: { 'x-real-ip': '10.0.0.1' },
|
||||
})
|
||||
@@ -212,7 +200,7 @@ describe('recordAudit', () => {
|
||||
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ipAddress: '10.0.0.1',
|
||||
ipAddress: null,
|
||||
userAgent: undefined,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { auditLog, db, user } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { createClientIpResolver } from '@sim/security/ip'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { AuditActionType, AuditResourceTypeValue } from './types'
|
||||
|
||||
const logger = createLogger('AuditLog')
|
||||
const clientIpResolver = createClientIpResolver(process.env.AUTH_TRUSTED_PROXIES)
|
||||
|
||||
export interface AuditLogParams {
|
||||
workspaceId?: string | null
|
||||
@@ -26,14 +28,6 @@ export interface AuditLogParams {
|
||||
request?: { headers: { get(name: string): string | null } }
|
||||
}
|
||||
|
||||
function getClientIp(request: { headers: { get(name: string): string | null } }): string {
|
||||
return (
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip')?.trim() ||
|
||||
'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget audit log write. Never throws; failures are logged.
|
||||
* Resolves actorName/actorEmail from the user table when both are omitted.
|
||||
@@ -90,7 +84,7 @@ function buildAuditRow(
|
||||
resourceName: params.resourceName,
|
||||
description: params.description,
|
||||
metadata: params.metadata ?? {},
|
||||
ipAddress: params.request ? getClientIp(params.request) : undefined,
|
||||
ipAddress: params.request ? clientIpResolver.resolve(params.request.headers) : undefined,
|
||||
userAgent: params.request?.headers.get('user-agent') ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
"types": "./src/hostnames.ts",
|
||||
"default": "./src/hostnames.ts"
|
||||
},
|
||||
"./ip": {
|
||||
"types": "./src/ip.ts",
|
||||
"default": "./src/ip.ts"
|
||||
},
|
||||
"./ssrf": {
|
||||
"types": "./src/ssrf.ts",
|
||||
"default": "./src/ssrf.ts"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createClientIpResolver, normalizeIpAddress } from '@sim/security/ip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function headers(values: Record<string, string>): Headers {
|
||||
return new Headers(values)
|
||||
}
|
||||
|
||||
describe('createClientIpResolver', () => {
|
||||
it('returns null when x-forwarded-for is absent', () => {
|
||||
const resolver = createClientIpResolver()
|
||||
|
||||
expect(resolver.resolve(headers({ 'x-real-ip': '192.0.2.10' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('uses a single forwarded address', () => {
|
||||
const resolver = createClientIpResolver()
|
||||
|
||||
expect(resolver.resolve(headers({ 'x-forwarded-for': '192.0.2.10' }))).toBe('192.0.2.10')
|
||||
})
|
||||
|
||||
it('ignores attacker-controlled values to the left of an appended peer', () => {
|
||||
const resolver = createClientIpResolver()
|
||||
|
||||
expect(resolver.resolve(headers({ 'x-forwarded-for': '198.51.100.20, 203.0.113.30' }))).toBe(
|
||||
'203.0.113.30'
|
||||
)
|
||||
})
|
||||
|
||||
it('walks past configured trusted proxy hops', () => {
|
||||
const resolver = createClientIpResolver('10.0.0.0/8, 2001:db8:abcd::/48')
|
||||
|
||||
expect(
|
||||
resolver.resolve(headers({ 'x-forwarded-for': '192.0.2.10, 10.0.0.12, 10.0.0.15' }))
|
||||
).toBe('192.0.2.10')
|
||||
expect(resolver.trustedProxies).toEqual(['10.0.0.0/8', '2001:db8:abcd::/48'])
|
||||
})
|
||||
|
||||
it('normalizes forwarded addresses with proxy-added ports', () => {
|
||||
const resolver = createClientIpResolver()
|
||||
|
||||
expect(resolver.resolve(headers({ 'x-forwarded-for': '192.0.2.10:4312' }))).toBe('192.0.2.10')
|
||||
expect(resolver.resolve(headers({ 'x-forwarded-for': '[2001:db8::1]:4312' }))).toBe(
|
||||
'2001:db8::1'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when a required hop is malformed', () => {
|
||||
const resolver = createClientIpResolver('10.0.0.0/8')
|
||||
|
||||
expect(resolver.resolve(headers({ 'x-forwarded-for': 'spoofed, 10.0.0.12' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when every forwarded hop is trusted', () => {
|
||||
const resolver = createClientIpResolver('10.0.0.0/8')
|
||||
|
||||
expect(resolver.resolve(headers({ 'x-forwarded-for': '10.0.0.12, 10.0.0.15' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('fails fast for invalid or catch-all trusted proxy configuration', () => {
|
||||
expect(() => createClientIpResolver('not-an-ip')).toThrow(
|
||||
'Invalid AUTH_TRUSTED_PROXIES entry "not-an-ip"'
|
||||
)
|
||||
expect(() => createClientIpResolver('0.0.0.0/0')).toThrow('catch-all networks')
|
||||
expect(() => createClientIpResolver('10.0.0.0/8,')).toThrow('cannot contain empty entries')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeIpAddress', () => {
|
||||
it('canonicalizes equivalent IPv6 and IPv4-mapped addresses', () => {
|
||||
expect(normalizeIpAddress('2001:0db8:0000:0000:0000:0000:0000:0001')).toBe('2001:db8::1')
|
||||
expect(normalizeIpAddress('::ffff:192.0.2.10')).toBe('192.0.2.10')
|
||||
})
|
||||
|
||||
it('rejects invalid addresses', () => {
|
||||
expect(normalizeIpAddress('not-an-ip')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as ipaddr from 'ipaddr.js'
|
||||
|
||||
type IpAddress = ipaddr.IPv4 | ipaddr.IPv6
|
||||
|
||||
interface TrustedNetwork {
|
||||
address: IpAddress
|
||||
prefixLength: number
|
||||
}
|
||||
|
||||
export interface ForwardedIpHeaders {
|
||||
get(name: string): string | null
|
||||
}
|
||||
|
||||
export interface ClientIpResolver {
|
||||
trustedProxies: string[]
|
||||
resolve(headers: ForwardedIpHeaders): string | null
|
||||
}
|
||||
|
||||
function parseForwardedAddress(value: string): IpAddress | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
let candidate = trimmed
|
||||
if (trimmed.startsWith('[')) {
|
||||
const match = /^\[([^\]]+)\](?::\d+)?$/.exec(trimmed)
|
||||
if (!match) return null
|
||||
candidate = match[1]
|
||||
} else if (trimmed.split(':').length === 2) {
|
||||
const match = /^([^:]+):\d+$/.exec(trimmed)
|
||||
if (match) candidate = match[1]
|
||||
}
|
||||
|
||||
if (!ipaddr.isValid(candidate)) return null
|
||||
const address = ipaddr.process(candidate)
|
||||
if (address.kind() === 'ipv6' && (address as ipaddr.IPv6).zoneId) return null
|
||||
return address
|
||||
}
|
||||
|
||||
/** Returns the canonical form of an IP literal, or `null` when it is invalid. */
|
||||
export function normalizeIpAddress(value: string): string | null {
|
||||
return parseForwardedAddress(value)?.toString() ?? null
|
||||
}
|
||||
|
||||
function parseTrustedNetwork(value: string): TrustedNetwork {
|
||||
if (value.includes('/')) {
|
||||
if (!ipaddr.isValidCIDR(value)) {
|
||||
throw new Error(`Invalid AUTH_TRUSTED_PROXIES entry "${value}"`)
|
||||
}
|
||||
const [address, prefixLength] = ipaddr.parseCIDR(value)
|
||||
if (prefixLength === 0) {
|
||||
throw new Error(
|
||||
`Invalid AUTH_TRUSTED_PROXIES entry "${value}": catch-all networks are unsafe`
|
||||
)
|
||||
}
|
||||
return { address, prefixLength }
|
||||
}
|
||||
|
||||
if (!ipaddr.isValid(value)) {
|
||||
throw new Error(`Invalid AUTH_TRUSTED_PROXIES entry "${value}"`)
|
||||
}
|
||||
const address = ipaddr.parse(value)
|
||||
return { address, prefixLength: address.kind() === 'ipv4' ? 32 : 128 }
|
||||
}
|
||||
|
||||
function isTrustedProxy(address: IpAddress, networks: TrustedNetwork[]): boolean {
|
||||
return networks.some(
|
||||
(network) =>
|
||||
network.address.kind() === address.kind() &&
|
||||
address.match(network.address, network.prefixLength)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a resolver for proxy-appended `X-Forwarded-For` chains. Resolution
|
||||
* walks right to left, skips only explicitly trusted proxy hops, and returns
|
||||
* the first untrusted address. With no trusted proxy list, the rightmost value
|
||||
* is the only safe address because a reverse proxy appends the network peer
|
||||
* after any client-supplied values.
|
||||
*/
|
||||
export function createClientIpResolver(trustedProxyConfig?: string): ClientIpResolver {
|
||||
const config = trustedProxyConfig?.trim()
|
||||
const trustedProxies = config ? config.split(',').map((entry) => entry.trim()) : []
|
||||
if (trustedProxies.some((entry) => entry.length === 0)) {
|
||||
throw new Error('AUTH_TRUSTED_PROXIES cannot contain empty entries')
|
||||
}
|
||||
const trustedNetworks = trustedProxies.map(parseTrustedNetwork)
|
||||
|
||||
return {
|
||||
trustedProxies,
|
||||
resolve(headers) {
|
||||
const forwardedFor = headers.get('x-forwarded-for')
|
||||
if (!forwardedFor) return null
|
||||
|
||||
const hops = forwardedFor.split(',')
|
||||
for (let index = hops.length - 1; index >= 0; index -= 1) {
|
||||
const address = parseForwardedAddress(hops[index])
|
||||
if (!address) return null
|
||||
if (!isTrustedProxy(address, trustedNetworks)) return address.toString()
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,9 @@ export function createMockFormDataRequest(
|
||||
*/
|
||||
export const requestUtilsMockFns = {
|
||||
mockGenerateRequestId: vi.fn(() => 'mock-request-id'),
|
||||
mockGetClientIp: vi.fn(() => '127.0.0.1'),
|
||||
mockGetClientIp: vi.fn(
|
||||
(_request: { headers: { get(name: string): string | null } }): string | null => '127.0.0.1'
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,5 +95,6 @@ export const requestUtilsMockFns = {
|
||||
export const requestUtilsMock = {
|
||||
generateRequestId: requestUtilsMockFns.mockGenerateRequestId,
|
||||
getClientIp: requestUtilsMockFns.mockGetClientIp,
|
||||
trustedProxies: [],
|
||||
noop: () => {},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user