Skip to content

Commit 4d9eee8

Browse files
waleedlatif1claude
andcommitted
fix(chat): bound deployed-chat callers and stop leaking chat gate config
Two authorization/throttling defects on chat deployments. **Denial of wallet on POST /api/chat/[identifier].** A deployed chat resolves its execution principal from the workflow's workspace, so the plan rate bucket, the usage/credit check and the concurrency reservation all belong to the owner while the request belongs to whoever found the link. Nothing bounded the caller, and an abort refunds none of it. Both the per-IP and the per-deployment bucket now run after auth and before `preprocessExecution`, on every execution regardless of `authType` — an email or SSO visitor is still not the payer. `GET /api/chat/validate` answered for any anonymous caller, so `available:false` inventoried live deployments; it now needs a session and a per-user bucket. **Chat gate config exposed at workflow `read` on GET /api/workflows/[id]/chat/ status.** The route reimplemented the admin-gated detail projection inline, serving the `allowedEmails` allow-list, `hasPassword` and the customization blob to any workspace viewer, and asserting no `deploy.chat` capability. It is now an adapter over `chat_deployments.list` — the same operation `GET /api/v2/chat- deployments` binds — returning only the deployment's id and identifier, which is all the editor reads before fetching the detail from `/api/chat/manage/{id}`. The two buckets are the existing `enforceIpRateLimitWithIndependentBackstop` plus a new `enforceResourceRateLimit` beside its siblings in `route-helpers`. The IP bucket is consulted first and returns on refusal, so one flooding IP cannot drain the deployment's budget and 429 the real audience with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QWh9WFMYNZ6uTFQFUzF8Bj
1 parent 178edba commit 4d9eee8

15 files changed

Lines changed: 537 additions & 161 deletions

File tree

apps/docs/content/docs/platform/self-hosting/environment-variables.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ Without a remote provider, user code runs in an in-process V8 isolate inside the
159159
| `API_MAX_JSON_BODY_BYTES` | 50 MB | Max JSON body on contract-validated API routes |
160160
| `CHAT_MAX_REQUEST_BYTES` | 220 MB | Max body on the public deployed-chat endpoint |
161161
| `WEBHOOK_MAX_REQUEST_BYTES` | 10 MB | Max body on public webhook receiver endpoints |
162+
| `DEPLOYMENT_IP_EXECUTIONS_PER_MINUTE` | `60` | Executions one client IP may drive against a single deployed chat |
163+
| `DEPLOYMENT_EXECUTIONS_PER_MINUTE` | `300` | Executions one deployed chat may serve per minute across all callers |
162164
| `WORKFLOW_EXECUTION_CONCURRENCY_LIMIT` | `75` | Workflow executions in parallel |
163165
| `WEBHOOK_EXECUTION_CONCURRENCY_LIMIT` | `75` | Webhook-triggered executions in parallel |
164166
| `SCHEDULE_EXECUTION_CONCURRENCY_LIMIT` | `30` | Scheduled executions in parallel |

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

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
workflowsApiUtilsMock,
1515
workflowsApiUtilsMockFns,
1616
} from '@sim/testing'
17+
import { NextResponse } from 'next/server'
1718
import { beforeEach, describe, expect, it, vi } from 'vitest'
1819

1920
/**
@@ -65,10 +66,18 @@ const createMockStream = () => {
6566
})
6667
}
6768

68-
const { mockValidateChatAuth, mockSetChatAuthCookie, mockProcessChatFiles } = vi.hoisted(() => ({
69+
const {
70+
mockValidateChatAuth,
71+
mockSetChatAuthCookie,
72+
mockProcessChatFiles,
73+
mockEnforceIpRateLimit,
74+
mockEnforceResourceRateLimit,
75+
} = vi.hoisted(() => ({
6976
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
7077
mockSetChatAuthCookie: vi.fn(),
7178
mockProcessChatFiles: vi.fn(),
79+
mockEnforceIpRateLimit: vi.fn(),
80+
mockEnforceResourceRateLimit: vi.fn(),
7281
}))
7382

7483
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
@@ -117,6 +126,11 @@ vi.mock('@/lib/core/utils/sse', () => ({
117126

118127
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
119128

129+
vi.mock('@/lib/core/rate-limiter', () => ({
130+
enforceIpRateLimitWithIndependentBackstop: mockEnforceIpRateLimit,
131+
enforceResourceRateLimit: mockEnforceResourceRateLimit,
132+
}))
133+
120134
import { preprocessExecution } from '@/lib/execution/preprocessing'
121135
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
122136
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
@@ -182,6 +196,8 @@ describe('Chat Identifier API Route', () => {
182196
})
183197

184198
mockValidateChatAuth.mockResolvedValue({ authorized: true })
199+
mockEnforceIpRateLimit.mockResolvedValue(null)
200+
mockEnforceResourceRateLimit.mockResolvedValue(null)
185201
mockProcessChatFiles.mockResolvedValue([])
186202
mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => {
187203
return new Response(
@@ -335,6 +351,72 @@ describe('Chat Identifier API Route', () => {
335351
expect(mockSetChatAuthCookie).toHaveBeenCalledWith(expect.anything(), passwordDeployment)
336352
})
337353

354+
describe('execution rate limit', () => {
355+
it.each([
356+
['per-IP', mockEnforceIpRateLimit],
357+
['per-deployment', mockEnforceResourceRateLimit],
358+
])("refuses on the %s bucket before the owner's budget is reserved", async (_, bucket) => {
359+
bucket.mockResolvedValue(
360+
NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
361+
)
362+
const req = createMockNextRequest('POST', { input: 'drain the wallet' })
363+
364+
const response = await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) })
365+
366+
expect(response.status).toBe(429)
367+
expect(preprocessExecution).not.toHaveBeenCalled()
368+
expect(createStreamingResponse).not.toHaveBeenCalled()
369+
expect(mockProcessChatFiles).not.toHaveBeenCalled()
370+
})
371+
372+
it('debits buckets keyed on the deployment, not the workflow', async () => {
373+
const req = createMockNextRequest('POST', { input: 'hello' })
374+
375+
await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) })
376+
377+
expect(mockEnforceIpRateLimit).toHaveBeenCalledWith(
378+
'chat-execute:chat-id',
379+
req,
380+
expect.objectContaining({ refillIntervalMs: 60_000 })
381+
)
382+
expect(mockEnforceResourceRateLimit).toHaveBeenCalledWith(
383+
'chat-execute',
384+
'chat-id',
385+
expect.objectContaining({ refillIntervalMs: 60_000 })
386+
)
387+
})
388+
389+
it('leaves the deployment bucket untouched when the IP bucket refuses', async () => {
390+
mockEnforceIpRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 }))
391+
const req = createMockNextRequest('POST', { input: 'flood' })
392+
393+
await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) })
394+
395+
expect(mockEnforceResourceRateLimit).not.toHaveBeenCalled()
396+
})
397+
398+
it('leaves the gate-configuration fetch unmetered', async () => {
399+
const passwordDeployment = {
400+
...mockChatResult[0],
401+
authType: 'password',
402+
password: 'encrypted-password',
403+
}
404+
dbChainMockFns.select.mockImplementation(() => ({
405+
from: vi.fn().mockReturnValue({
406+
where: vi.fn().mockReturnValue({
407+
limit: vi.fn().mockReturnValue([passwordDeployment]),
408+
}),
409+
}),
410+
}))
411+
const req = createMockNextRequest('POST', { password: 'test-password' })
412+
413+
await POST(req, { params: Promise.resolve({ identifier: 'password-protected-chat' }) })
414+
415+
expect(mockEnforceIpRateLimit).not.toHaveBeenCalled()
416+
expect(mockEnforceResourceRateLimit).not.toHaveBeenCalled()
417+
})
418+
})
419+
338420
it('should return 400 for requests without input', async () => {
339421
const req = createMockNextRequest('POST', {})
340422
const params = Promise.resolve({ identifier: 'test-chat' })

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import { deployedChatPostContract } from '@/lib/api/contracts/chats'
88
import { parseRequest } from '@/lib/api/server'
99
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
1010
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
11-
import { env } from '@/lib/core/config/env'
11+
import { env, envNumber } from '@/lib/core/config/env'
12+
import {
13+
enforceIpRateLimitWithIndependentBackstop,
14+
enforceResourceRateLimit,
15+
type TokenBucketConfig,
16+
} from '@/lib/core/rate-limiter'
1217
import { generateRequestId } from '@/lib/core/utils/request'
1318
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1419
import { preprocessExecution } from '@/lib/execution/preprocessing'
@@ -49,6 +54,29 @@ export const runtime = 'nodejs'
4954

5055
const CHAT_MAX_REQUEST_BYTES = Number.parseInt(env.CHAT_MAX_REQUEST_BYTES, 10) || 220 * 1024 * 1024
5156

57+
/** A per-minute ceiling, as a bucket that refills its whole allowance each minute. */
58+
function executionsPerMinute(value: string | undefined, fallback: number): TokenBucketConfig {
59+
const perMinute = envNumber(value, fallback, { min: 1, integer: true })
60+
return { maxTokens: perMinute, refillRate: perMinute, refillIntervalMs: 60_000 }
61+
}
62+
63+
/**
64+
* Executions one client IP may drive against a single deployed chat.
65+
*
66+
* A deployed chat runs the owner's workflow on the owner's plan bucket, credit
67+
* balance and concurrency reservation for whoever holds the link, so every
68+
* ceiling on that path belongs to the payer and none of them bound the caller.
69+
* Sized well above a human conversation and above shared-NAT aggregation, so it
70+
* costs a flooder a botnet rather than costing a real audience its session.
71+
*/
72+
const CHAT_EXECUTION_IP_LIMIT = executionsPerMinute(env.DEPLOYMENT_IP_EXECUTIONS_PER_MINUTE, 60)
73+
74+
/**
75+
* What bounds the owner's exposure when attempts are spread across addresses,
76+
* and the only bound left when the proxy chain resolves to no client IP.
77+
*/
78+
const CHAT_EXECUTION_LIMIT = executionsPerMinute(env.DEPLOYMENT_EXECUTIONS_PER_MINUTE, 300)
79+
5280
export const POST = withRouteHandler(
5381
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
5482
const { identifier } = await context.params
@@ -169,6 +197,22 @@ export const POST = withRouteHandler(
169197
return createErrorResponse('No input provided', 400)
170198
}
171199

200+
// Both buckets apply regardless of the chat's auth type: an email or SSO
201+
// visitor is still not the payer.
202+
const ipLimited = await enforceIpRateLimitWithIndependentBackstop(
203+
`chat-execute:${deployment.id}`,
204+
request,
205+
CHAT_EXECUTION_IP_LIMIT
206+
)
207+
if (ipLimited) return ipLimited
208+
209+
const deploymentLimited = await enforceResourceRateLimit(
210+
'chat-execute',
211+
deployment.id,
212+
CHAT_EXECUTION_LIMIT
213+
)
214+
if (deploymentLimited) return deploymentLimited
215+
172216
const executionId = generateId()
173217

174218
const loggingSession = new LoggingSession(
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Tests for the chat identifier availability endpoint.
3+
*
4+
* @vitest-environment node
5+
*/
6+
import { authMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
7+
import { NextRequest, NextResponse } from 'next/server'
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
const { mockEnforceUserRateLimit } = vi.hoisted(() => ({
11+
mockEnforceUserRateLimit: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/core/rate-limiter', () => ({
15+
enforceUserRateLimit: mockEnforceUserRateLimit,
16+
}))
17+
18+
import { GET } from '@/app/api/chat/validate/route'
19+
20+
function request(identifier: string) {
21+
return new NextRequest(`http://localhost:3000/api/chat/validate?identifier=${identifier}`)
22+
}
23+
24+
describe('chat identifier validation route', () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks()
27+
resetDbChainMock()
28+
authMockFns.mockGetSession.mockResolvedValue({
29+
user: { id: 'user-1' },
30+
session: { id: 'session-1' },
31+
})
32+
mockEnforceUserRateLimit.mockResolvedValue(null)
33+
})
34+
35+
it('refuses an anonymous caller before answering', async () => {
36+
authMockFns.mockGetSession.mockResolvedValue(null)
37+
38+
const response = await GET(request('assistant'))
39+
40+
expect(response.status).toBe(401)
41+
expect(mockEnforceUserRateLimit).not.toHaveBeenCalled()
42+
})
43+
44+
it('reports a taken identifier to a signed-in caller', async () => {
45+
queueTableRows(schemaMock.chat, [{ id: 'chat-1' }])
46+
47+
const response = await GET(request('assistant'))
48+
49+
expect(response.status).toBe(200)
50+
expect(await response.json()).toEqual({
51+
available: false,
52+
error: 'This identifier is already in use',
53+
})
54+
})
55+
56+
it('reports a free identifier to a signed-in caller', async () => {
57+
const response = await GET(request('bot'))
58+
59+
expect(response.status).toBe(200)
60+
expect(await response.json()).toEqual({ available: true, error: null })
61+
})
62+
63+
it('caps how far one caller can walk a dictionary', async () => {
64+
mockEnforceUserRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 }))
65+
66+
const response = await GET(request('support'))
67+
68+
expect(response.status).toBe(429)
69+
expect(mockEnforceUserRateLimit).toHaveBeenCalledWith(
70+
'chat-identifier-check',
71+
'user-1',
72+
expect.objectContaining({ maxTokens: 60, refillIntervalMs: 60_000 })
73+
)
74+
})
75+
})

apps/sim/app/api/chat/validate/route.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,39 @@ import { and, eq, isNull } from 'drizzle-orm'
55
import type { NextRequest } from 'next/server'
66
import { identifierValidationQuerySchema } from '@/lib/api/contracts/chats'
77
import { getValidationErrorMessage } from '@/lib/api/server'
8+
import { getSession } from '@/lib/auth'
9+
import { enforceUserRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter'
810
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
911
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1012

1113
const logger = createLogger('ChatValidateAPI')
1214

1315
/**
14-
* GET endpoint to validate chat identifier availability
16+
* Caps how far one caller can walk a dictionary of identifiers. Sized for a
17+
* debounced availability field, which sends one request per pause in typing.
18+
*/
19+
const IDENTIFIER_CHECK_RATE_LIMIT: TokenBucketConfig = {
20+
maxTokens: 60,
21+
refillRate: 60,
22+
refillIntervalMs: 60_000,
23+
}
24+
25+
/**
26+
* GET endpoint to validate chat identifier availability.
27+
*
28+
* Chat identifiers are globally unique, so availability cannot be scoped to a
29+
* workspace and there is no resource here to authorize. What the endpoint must
30+
* not be is anonymous: `available: false` names a live deployment, and the chat
31+
* behind it executes its owner's workflow on their budget for anyone holding
32+
* the identifier, so an unmetered answer is a deployment inventory.
1533
*/
1634
export const GET = withRouteHandler(async (request: NextRequest) => {
1735
try {
36+
const session = await getSession()
37+
if (!session?.user?.id) {
38+
return createErrorResponse('Unauthorized', 401)
39+
}
40+
1841
const { searchParams } = new URL(request.url)
1942
const identifier = searchParams.get('identifier')
2043

@@ -34,6 +57,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3457
return createErrorResponse(errorMessage, 400)
3558
}
3659

60+
const rateLimited = await enforceUserRateLimit(
61+
'chat-identifier-check',
62+
session.user.id,
63+
IDENTIFIER_CHECK_RATE_LIMIT
64+
)
65+
if (rateLimited) return rateLimited
66+
3767
const { identifier: validatedIdentifier } = validation.data
3868

3969
const existingChat = await db

0 commit comments

Comments
 (0)