Skip to content

Commit 46c4a14

Browse files
fix(security): fail closed without client IP
1 parent 40d573d commit 46c4a14

10 files changed

Lines changed: 123 additions & 68 deletions

File tree

apps/sim/app/api/chat/utils.test.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import {
77
authMockFns,
8+
createMockRequest,
89
encryptionMock,
910
encryptionMockFns,
1011
loggingSessionMock,
@@ -290,10 +291,7 @@ describe('Chat API Utils', () => {
290291
authType: 'password',
291292
password: 'encrypted-password',
292293
}
293-
const mockRequest = {
294-
method: 'POST',
295-
cookies: { get: vi.fn().mockReturnValue(null) },
296-
} as any
294+
const mockRequest = createMockRequest('POST')
297295
const candidate = 'password-attempt-fixture'
298296

299297
const result = await validateChatAuth('request-id', deployment, mockRequest, {
@@ -319,13 +317,11 @@ describe('Chat API Utils', () => {
319317
authType: 'password',
320318
password: 'encrypted-password',
321319
}
322-
const mockRequest = {
323-
method: 'POST',
324-
cookies: { get: vi.fn().mockReturnValue(null) },
325-
} as any
320+
const mockRequest = createMockRequest('POST')
321+
const candidate = 'correct-password'
326322

327323
const result = await validateChatAuth('request-id', deployment, mockRequest, {
328-
password: 'correct-password',
324+
password: candidate,
329325
})
330326

331327
expect(result.authorized).toBe(true)

apps/sim/app/api/contact/route.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,22 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
5454

5555
try {
5656
const ip = getClientIp(req)
57-
if (ip) {
58-
const storageKey = `public:contact:${ip}`
59-
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
60-
storageKey,
61-
PUBLIC_ENDPOINT_RATE_LIMIT
62-
)
63-
64-
if (!allowed) {
65-
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
66-
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, {
67-
status: 429,
68-
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
69-
})
70-
}
57+
if (!ip) {
58+
logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`)
59+
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 })
60+
}
61+
const storageKey = `public:contact:${ip}`
62+
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
63+
storageKey,
64+
PUBLIC_ENDPOINT_RATE_LIMIT
65+
)
66+
67+
if (!allowed) {
68+
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
69+
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, {
70+
status: 429,
71+
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
72+
})
7173
}
7274

7375
const parsed = await parseRequest(submitContactContract, req, {})
@@ -90,7 +92,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
9092
typeof captchaToken === 'string' && captchaToken.length > 0 ? captchaToken : null
9193

9294
if (token) {
93-
const verification = await verifyTurnstileToken({ token, remoteIp: ip ?? undefined })
95+
const verification = await verifyTurnstileToken({ token, remoteIp: ip })
9496
if (verification.success) {
9597
captchaVerified = true
9698
} else if (!verification.transportError) {
@@ -111,10 +113,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
111113
}
112114

113115
if (!captchaVerified) {
114-
if (!ip) {
115-
logger.warn(`[${requestId}] Cannot enforce no-captcha rate limit without a client IP`)
116-
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 })
117-
}
118116
const nocaptchaKey = `public:contact:nocaptcha:${ip}`
119117
const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect(
120118
nocaptchaKey,

apps/sim/app/api/demo-requests/route.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,28 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
2828

2929
try {
3030
const ip = getClientIp(req)
31-
if (ip) {
32-
const storageKey = `public:demo-request:${ip}`
33-
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
34-
storageKey,
35-
PUBLIC_ENDPOINT_RATE_LIMIT
31+
if (!ip) {
32+
logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`)
33+
return NextResponse.json(
34+
{ error: 'Too many requests. Please try again later.' },
35+
{ status: 429 }
3636
)
37+
}
38+
const storageKey = `public:demo-request:${ip}`
39+
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
40+
storageKey,
41+
PUBLIC_ENDPOINT_RATE_LIMIT
42+
)
3743

38-
if (!allowed) {
39-
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
40-
return NextResponse.json(
41-
{ error: 'Too many requests. Please try again later.' },
42-
{
43-
status: 429,
44-
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
45-
}
46-
)
47-
}
44+
if (!allowed) {
45+
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
46+
return NextResponse.json(
47+
{ error: 'Too many requests. Please try again later.' },
48+
{
49+
status: 429,
50+
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
51+
}
52+
)
4853
}
4954

5055
const parsed = await parseRequest(submitDemoRequestContract, req, {})

apps/sim/app/api/help/integration-request/route.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,28 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
2626

2727
try {
2828
const ip = getClientIp(req)
29-
if (ip) {
30-
const storageKey = `public:integration-request:${ip}`
31-
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
32-
storageKey,
33-
PUBLIC_ENDPOINT_RATE_LIMIT
29+
if (!ip) {
30+
logger.warn(`[${requestId}] Unable to resolve client IP for public rate limit`)
31+
return NextResponse.json(
32+
{ error: 'Too many requests. Please try again later.' },
33+
{ status: 429 }
3434
)
35+
}
36+
const storageKey = `public:integration-request:${ip}`
37+
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
38+
storageKey,
39+
PUBLIC_ENDPOINT_RATE_LIMIT
40+
)
3541

36-
if (!allowed) {
37-
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
38-
return NextResponse.json(
39-
{ error: 'Too many requests. Please try again later.' },
40-
{
41-
status: 429,
42-
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
43-
}
44-
)
45-
}
42+
if (!allowed) {
43+
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
44+
return NextResponse.json(
45+
{ error: 'Too many requests. Please try again later.' },
46+
{
47+
status: 429,
48+
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
49+
}
50+
)
4651
}
4752

4853
const parsed = await parseRequest(

apps/sim/lib/api/server/routes/v2-json-route.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,16 @@ export const v2OrchestrationErrorPolicy = {
277277

278278
async function enforceV2PreAuthIpLimit(request: NextRequest): Promise<NextResponse | null> {
279279
const ip = getClientIp(request)
280-
if (!ip) return null
280+
if (!ip) {
281+
const resetAt = new Date(Date.now() + V2_PREAUTH_IP_LIMIT.refillIntervalMs)
282+
return v2RateLimitError({
283+
allowed: false,
284+
limit: V2_PREAUTH_IP_LIMIT.maxTokens,
285+
remaining: 0,
286+
resetAt,
287+
retryAfterMs: V2_PREAUTH_IP_LIMIT.refillIntervalMs,
288+
})
289+
}
281290
const abuseLimit = await rateLimiter.checkRateLimitDirect(
282291
`v2:preauth:ip:${ip}`,
283292
V2_PREAUTH_IP_LIMIT,

apps/sim/lib/core/rate-limiter/route-helpers.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,14 @@ describe('route-helpers rate limiting', () => {
116116
)
117117
})
118118

119-
it('does not create a shared bucket when the client IP cannot be resolved', async () => {
119+
it('fails closed without creating a shared bucket when the client IP cannot be resolved', async () => {
120120
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
121121
const request = createMockRequest('POST')
122122

123123
const result = await enforceIpRateLimit('otp', request)
124124

125-
expect(result).toBeNull()
125+
expect(result?.status).toBe(429)
126+
expect(result?.headers.get('Retry-After')).toBe('60')
126127
expect(consume).not.toHaveBeenCalled()
127128
})
128129

apps/sim/lib/core/rate-limiter/route-helpers.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,17 @@ export async function enforceUserRateLimit(
5656
return buildRateLimitResponse(resetAt)
5757
}
5858

59-
/** Apply a per-IP token bucket when the forwarded chain resolves safely. */
59+
/** Apply a per-IP token bucket and fail closed when the client cannot be resolved safely. */
6060
export async function enforceIpRateLimit(
6161
bucketName: string,
6262
request: NextRequest,
6363
config: TokenBucketConfig = DEFAULT_PUBLIC_IP_ROUTE_LIMIT
6464
): Promise<NextResponse | null> {
6565
const ip = getClientIp(request)
66-
if (!ip) return null
66+
if (!ip) {
67+
logger.warn('Unable to resolve client IP for public rate limit', { bucket: bucketName })
68+
return buildRateLimitResponse(new Date(Date.now() + config.refillIntervalMs))
69+
}
6770
const key = `route:${bucketName}:ip:${ip}`
6871
const { allowed, resetAt } = await rateLimiter.checkRateLimitDirect(key, config)
6972
if (allowed) return null

apps/sim/lib/credential-groups/rate-limit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export async function enforcePublicCredentialGroupIpRateLimit(
6565
): Promise<NextResponse | null> {
6666
const config = configForPublicScope(scope)
6767
const ip = getClientIp(request)
68-
if (!ip) return null
68+
if (!ip) return rateLimitResponse(undefined, config.refillIntervalMs)
6969
const result = await rateLimiter.checkRateLimitDirect(
7070
`public-credential-group:${scope}:ip:${ip}`,
7171
config,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { requestUtilsMockFns } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
8+
mockCheckRateLimitDirect: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/core/rate-limiter', () => ({
12+
RateLimiter: class {
13+
checkRateLimitDirect = mockCheckRateLimitDirect
14+
},
15+
}))
16+
17+
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
18+
19+
describe('enforcePublicFileRateLimit', () => {
20+
beforeEach(() => {
21+
vi.clearAllMocks()
22+
})
23+
24+
it('fails closed without creating a shared bucket when the client IP cannot be resolved', async () => {
25+
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null)
26+
27+
const response = await enforcePublicFileRateLimit(new Request('http://localhost'), 'content')
28+
29+
expect(response?.status).toBe(429)
30+
expect(response?.headers.get('Retry-After')).toBe('60')
31+
expect(mockCheckRateLimitDirect).not.toHaveBeenCalled()
32+
})
33+
})

apps/sim/lib/public-shares/rate-limit.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,21 @@ const CONTENT_RATE_LIMIT: TokenBucketConfig = {
2222
* Per-IP rate limit for the unauthenticated public share endpoints, returning a
2323
* `429` response when exceeded (or `null` to proceed). The token is unguessable,
2424
* so this defends a *known* link against hammering (DoS / S3 egress) rather than
25-
* enumeration. Fails open on storage errors (availability over strictness),
26-
* matching the chat public route.
25+
* enumeration. Fails open on storage errors (availability over strictness), but
26+
* fails closed when the forwarded chain cannot identify a safe client key.
2727
*/
2828
export async function enforcePublicFileRateLimit(
2929
request: { headers: { get(name: string): string | null } },
3030
scope: 'metadata' | 'content'
3131
): Promise<NextResponse | null> {
32-
const ip = getClientIp(request)
33-
if (!ip) return null
3432
const config = scope === 'content' ? CONTENT_RATE_LIMIT : METADATA_RATE_LIMIT
33+
const ip = getClientIp(request)
34+
if (!ip) {
35+
return NextResponse.json(
36+
{ error: 'Too many requests. Please try again later.' },
37+
{ status: 429, headers: { 'Retry-After': String(config.refillIntervalMs / 1000) } }
38+
)
39+
}
3540
const result = await rateLimiter.checkRateLimitDirect(`public-file:${scope}:${ip}`, config)
3641
if (result.allowed) return null
3742

0 commit comments

Comments
 (0)