Skip to content

Commit 568a552

Browse files
authored
fix(rate-limit): close rate-limit bypass and tighten public route limits (#4591)
* fix(rate-limit): close rate-limit bypass and tighten public route limits * fix(rate-limit): address PR review — drop success field from 429 body, fall back to per-IP when JWT auth lacks userId
1 parent 1c111ff commit 568a552

22 files changed

Lines changed: 466 additions & 53 deletions

File tree

apps/sim/app/api/auth/socket-token/route.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { headers } from 'next/headers'
4-
import { NextResponse } from 'next/server'
4+
import { type NextRequest, NextResponse } from 'next/server'
55
import { auth } from '@/lib/auth'
66
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
7+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89

910
const logger = createLogger('SocketTokenAPI')
1011

11-
export const POST = withRouteHandler(async () => {
12+
export const POST = withRouteHandler(async (request: NextRequest) => {
1213
if (isAuthDisabled) {
1314
return NextResponse.json({ token: 'anonymous-socket-token' })
1415
}
1516

17+
const rateLimited = await enforceIpRateLimit('socket-token', request, {
18+
maxTokens: 30,
19+
refillRate: 30,
20+
refillIntervalMs: 60_000,
21+
})
22+
if (rateLimited) return rateLimited
23+
1624
try {
1725
const hdrs = await headers()
1826
const response = await auth.api.generateOneTimeToken({

apps/sim/app/api/auth/sso/providers/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
55
import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
66
import { parseRequest } from '@/lib/api/server'
77
import { getSession } from '@/lib/auth'
8+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
89
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1011

@@ -13,6 +14,14 @@ const logger = createLogger('SSOProvidersRoute')
1314
export const GET = withRouteHandler(async (request: NextRequest) => {
1415
try {
1516
const session = await getSession()
17+
if (!session?.user?.id) {
18+
const rateLimited = await enforceIpRateLimit('sso-providers', request, {
19+
maxTokens: 20,
20+
refillRate: 20,
21+
refillIntervalMs: 60_000,
22+
})
23+
if (rateLimited) return rateLimited
24+
}
1625
const parsed = await parseRequest(listSsoProvidersContract, request, {})
1726
if (!parsed.success) return parsed.response
1827
const { organizationId } = parsed.data.query

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ describe('Chat OTP API Route', () => {
423423
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
424424
})
425425

426-
it('skips IP rate limit when client IP is unknown', async () => {
426+
it('folds spoofed `unknown` client IPs into a single shared bucket', async () => {
427427
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown')
428428
buildDeploymentSelect()
429429

@@ -434,8 +434,11 @@ describe('Chat OTP API Route', () => {
434434

435435
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
436436

437-
// Only the email-scoped check should run, not the IP-scoped one
438-
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1)
437+
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2)
438+
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
439+
expect.stringMatching(/^chat-otp:ip:.*:unknown$/),
440+
expect.any(Object)
441+
)
439442
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
440443
expect.stringContaining('chat-otp:email:'),
441444
expect.any(Object)

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -223,20 +223,18 @@ export const POST = withRouteHandler(
223223

224224
try {
225225
const ip = getClientIp(request)
226-
if (ip !== 'unknown') {
227-
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
228-
`chat-otp:ip:${identifier}:${ip}`,
229-
OTP_IP_RATE_LIMIT
226+
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
227+
`chat-otp:ip:${identifier}:${ip}`,
228+
OTP_IP_RATE_LIMIT
229+
)
230+
if (!ipRateLimit.allowed) {
231+
logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
232+
const retryAfter = Math.ceil(
233+
(ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000
230234
)
231-
if (!ipRateLimit.allowed) {
232-
logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
233-
const retryAfter = Math.ceil(
234-
(ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000
235-
)
236-
const response = createErrorResponse('Too many requests. Please try again later.', 429)
237-
response.headers.set('Retry-After', String(retryAfter))
238-
return addCorsHeaders(response, request)
239-
}
235+
const response = createErrorResponse('Too many requests. Please try again later.', 429)
236+
response.headers.set('Retry-After', String(retryAfter))
237+
return addCorsHeaders(response, request)
240238
}
241239

242240
const parsed = await parseRequest(requestChatEmailOtpContract, request, context, {

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,18 @@ export const POST = withRouteHandler(
3030
const requestId = generateRequestId()
3131

3232
const ip = getClientIp(request)
33-
if (ip !== 'unknown') {
34-
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
35-
`chat-sso:ip:${ip}`,
36-
SSO_IP_RATE_LIMIT
33+
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
34+
`chat-sso:ip:${ip}`,
35+
SSO_IP_RATE_LIMIT
36+
)
37+
if (!ipRateLimit.allowed) {
38+
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
39+
const retryAfter = Math.ceil(
40+
(ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000
3741
)
38-
if (!ipRateLimit.allowed) {
39-
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
40-
const retryAfter = Math.ceil(
41-
(ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000
42-
)
43-
const response = createErrorResponse('Too many requests. Please try again later.', 429)
44-
response.headers.set('Retry-After', String(retryAfter))
45-
return addCorsHeaders(response, request)
46-
}
42+
const response = createErrorResponse('Too many requests. Please try again later.', 429)
43+
response.headers.set('Retry-After', String(retryAfter))
44+
return addCorsHeaders(response, request)
4745
}
4846

4947
const parsed = await parseRequest(chatSSOContract, request, context)

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { telemetryContract } from '@/lib/api/contracts/telemetry'
44
import { parseRequest } from '@/lib/api/server'
55
import { env } from '@/lib/core/config/env'
66
import { isProd } from '@/lib/core/config/feature-flags'
7+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89

910
const logger = createLogger('TelemetryAPI')
@@ -148,6 +149,13 @@ async function forwardToCollector(data: Record<string, unknown>): Promise<boolea
148149
* Endpoint that receives telemetry events and forwards them to OpenTelemetry collector
149150
*/
150151
export const POST = withRouteHandler(async (req: NextRequest) => {
152+
const rateLimited = await enforceIpRateLimit('telemetry', req, {
153+
maxTokens: 60,
154+
refillRate: 30,
155+
refillIntervalMs: 60_000,
156+
})
157+
if (rateLimited) return rateLimited
158+
151159
try {
152160
const parsed = await parseRequest(telemetryContract, req, {})
153161
if (!parsed.success) return parsed.response

apps/sim/app/api/templates/[id]/route.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server'
77
import { templateIdParamsSchema, updateTemplateContract } from '@/lib/api/contracts/templates'
88
import { parseRequest } from '@/lib/api/server'
99
import { getSession } from '@/lib/auth'
10-
import { generateRequestId } from '@/lib/core/utils/request'
10+
import { RateLimiter } from '@/lib/core/rate-limiter'
11+
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
1112
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1213
import { canAccessTemplate } from '@/lib/templates/permissions'
1314
import {
@@ -18,6 +19,18 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
1819

1920
const logger = createLogger('TemplateByIdAPI')
2021

22+
const viewRateLimiter = new RateLimiter()
23+
24+
/**
25+
* Per-IP, per-template view-counter dedup bucket: one increment per 10 minutes.
26+
* Prevents scripted inflation of `templates.views` from the public GET handler.
27+
*/
28+
const TEMPLATE_VIEW_DEDUP = {
29+
maxTokens: 1,
30+
refillRate: 1,
31+
refillIntervalMs: 10 * 60_000,
32+
}
33+
2134
export const revalidate = 0
2235

2336
export const GET = withRouteHandler(
@@ -63,21 +76,31 @@ export const GET = withRouteHandler(
6376
isStarred = starResult.length > 0
6477
}
6578

66-
const shouldIncrementView = template.status === 'approved'
79+
let shouldIncrementView = template.status === 'approved'
6780

6881
if (shouldIncrementView) {
69-
try {
70-
await db
71-
.update(templates)
72-
.set({
73-
views: sql`${templates.views} + 1`,
74-
})
75-
.where(eq(templates.id, id))
76-
} catch (viewError) {
77-
logger.warn(
78-
`[${requestId}] Failed to increment view count for template: ${id}`,
79-
viewError
80-
)
82+
const viewer = session?.user?.id ?? `ip:${getClientIp(request)}`
83+
const dedupKey = `template-view:${id}:${viewer}`
84+
const { allowed } = await viewRateLimiter.checkRateLimitDirect(
85+
dedupKey,
86+
TEMPLATE_VIEW_DEDUP
87+
)
88+
if (!allowed) {
89+
shouldIncrementView = false
90+
} else {
91+
try {
92+
await db
93+
.update(templates)
94+
.set({
95+
views: sql`${templates.views} + 1`,
96+
})
97+
.where(eq(templates.id, id))
98+
} catch (viewError) {
99+
logger.warn(
100+
`[${requestId}] Failed to increment view count for template: ${id}`,
101+
viewError
102+
)
103+
}
81104
}
82105
}
83106

apps/sim/app/api/tools/a2a/cancel-task/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createA2AClient } from '@/lib/a2a/utils'
55
import { a2aCancelTaskContract } from '@/lib/api/contracts/tools/a2a'
66
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
77
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
8+
import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
89
import { generateRequestId } from '@/lib/core/utils/request'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1011

@@ -29,6 +30,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2930
)
3031
}
3132

33+
const rateLimited = await enforceUserOrIpRateLimit(
34+
'a2a-cancel-task',
35+
authResult.userId,
36+
request
37+
)
38+
if (rateLimited) return rateLimited
39+
3240
const parsed = await parseRequest(
3341
a2aCancelTaskContract,
3442
request,

apps/sim/app/api/tools/a2a/delete-push-notification/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createA2AClient } from '@/lib/a2a/utils'
44
import { a2aDeletePushNotificationContract } from '@/lib/api/contracts/tools/a2a'
55
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7+
import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
78
import { generateRequestId } from '@/lib/core/utils/request'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
910

@@ -30,6 +31,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3031
)
3132
}
3233

34+
const rateLimited = await enforceUserOrIpRateLimit(
35+
'a2a-delete-push-notification',
36+
authResult.userId,
37+
request
38+
)
39+
if (rateLimited) return rateLimited
40+
3341
logger.info(
3442
`[${requestId}] Authenticated A2A delete push notification request via ${authResult.authType}`,
3543
{

apps/sim/app/api/tools/a2a/get-agent-card/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createA2AClient } from '@/lib/a2a/utils'
44
import { a2aGetAgentCardContract } from '@/lib/api/contracts/tools/a2a'
55
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7+
import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
78
import { generateRequestId } from '@/lib/core/utils/request'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
910

@@ -28,6 +29,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2829
)
2930
}
3031

32+
const rateLimited = await enforceUserOrIpRateLimit(
33+
'a2a-get-agent-card',
34+
authResult.userId,
35+
request
36+
)
37+
if (rateLimited) return rateLimited
38+
3139
logger.info(
3240
`[${requestId}] Authenticated A2A get agent card request via ${authResult.authType}`,
3341
{

0 commit comments

Comments
 (0)