Skip to content

Commit e2e29ee

Browse files
authored
fix(api): bound request-body reads on speech/knowledge-chunks/help routes (#5601)
* fix(api): bound request-body reads on speech/knowledge-chunks/help routes Replace unbounded request.json()/formData() reads with the existing size-limited helpers, and move auth ahead of the body read on the knowledge chunks route so unauthenticated callers can't force a large allocation before being rejected. * fix(knowledge): reject non-string workflowId instead of silently skipping authorization A truthy non-string workflowId previously fell through the type guard and skipped the workflow-scoped write authorization entirely. Validate the type explicitly and fail closed with a 400 instead.
1 parent b486aba commit e2e29ee

4 files changed

Lines changed: 53 additions & 7 deletions

File tree

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,26 @@ import { helpFormBodySchema } from '@/lib/api/contracts/common'
55
import { validationErrorResponse } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
77
import { generateRequestId } from '@/lib/core/utils/request'
8+
import {
9+
isPayloadSizeLimitError,
10+
MAX_MULTIPART_OVERHEAD_BYTES,
11+
readFormDataWithLimit,
12+
} from '@/lib/core/utils/stream-limits'
813
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
914
import { sendEmail } from '@/lib/messaging/email/mailer'
1015
import { getFromEmailAddress, getHelpEmailAddress } from '@/lib/messaging/email/utils'
16+
import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
1117

1218
const logger = createLogger('HelpAPI')
1319

20+
/**
21+
* The form can carry several image attachments with no server-side count
22+
* cap, so this reuses the repo's largest existing per-request form-data
23+
* bound (see files/upload route) rather than an arbitrary smaller limit
24+
* that could reject a legitimate multi-image submission.
25+
*/
26+
const MAX_HELP_FORM_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES
27+
1428
export const POST = withRouteHandler(async (req: NextRequest) => {
1529
const requestId = generateRequestId()
1630

@@ -23,7 +37,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
2337

2438
const email = session.user.email
2539

26-
const formData = await req.formData()
40+
const formData = await readFormDataWithLimit(req, {
41+
maxBytes: MAX_HELP_FORM_BYTES,
42+
label: 'Help request form data',
43+
})
2744

2845
const subject = formData.get('subject') as string
2946
const message = formData.get('message') as string
@@ -128,6 +145,13 @@ ${message}
128145
{ status: 200 }
129146
)
130147
} catch (error) {
148+
if (isPayloadSizeLimitError(error)) {
149+
logger.warn(`[${requestId}] Help request form data too large`, { message: error.message })
150+
return NextResponse.json(
151+
{ error: `Request body exceeds the maximum allowed size of ${MAX_HELP_FORM_BYTES} bytes` },
152+
{ status: 413 }
153+
)
154+
}
131155
if (error instanceof Error && error.message.includes('not configured')) {
132156
logger.error(`[${requestId}] Email service configuration error`, error)
133157
return NextResponse.json(

apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
createChunkBodySchema,
88
listKnowledgeChunksQuerySchema,
99
} from '@/lib/api/contracts/knowledge'
10-
import { isZodError, parseRequest } from '@/lib/api/server'
10+
import { isZodError, parseJsonBody, parseRequest } from '@/lib/api/server'
1111
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1212
import { generateRequestId } from '@/lib/core/utils/request'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -103,17 +103,21 @@ export const POST = withRouteHandler(
103103
const { id: knowledgeBaseId, documentId } = await params
104104

105105
try {
106-
const body = await req.json()
107-
const { workflowId, ...searchParams } = body
108-
109106
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
110107
if (!auth.success || !auth.userId) {
111108
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`)
112109
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
113110
}
114111
const userId = auth.userId
115112

113+
const parsedBody = await parseJsonBody(req)
114+
if (!parsedBody.success) return parsedBody.response
115+
const { workflowId, ...searchParams } = parsedBody.data as Record<string, unknown>
116+
116117
if (workflowId) {
118+
if (typeof workflowId !== 'string') {
119+
return NextResponse.json({ error: 'workflowId must be a string' }, { status: 400 })
120+
}
117121
const authorization = await authorizeWorkflowByWorkspacePermission({
118122
workflowId,
119123
userId,

apps/sim/app/api/speech/token/route.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,13 @@ describe('POST /api/speech/token — usage attribution', () => {
138138
workspaceId: 'ws-1',
139139
})
140140
})
141+
142+
it('rejects an oversized body before any auth/billing work runs', async () => {
143+
const oversizedBody = { chatId: 'x'.repeat(64 * 1024) }
144+
const res = await POST(createMockRequest('POST', oversizedBody))
145+
146+
expect(res.status).toBe(413)
147+
expect(mockGetSession).not.toHaveBeenCalled()
148+
expect(mockRecordUsage).not.toHaveBeenCalled()
149+
})
141150
})

apps/sim/app/api/speech/token/route.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
66
import { eq } from 'drizzle-orm'
77
import { type NextRequest, NextResponse } from 'next/server'
88
import { speechTokenBodySchema } from '@/lib/api/contracts/media/speech'
9+
import { parseOptionalJsonBody } from '@/lib/api/server'
910
import { getSession } from '@/lib/auth'
1011
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
1112
import { recordUsage } from '@/lib/billing/core/usage-log'
@@ -34,6 +35,13 @@ const STT_TOKEN_RATE_LIMIT = {
3435
refillIntervalMs: 72 * 1000,
3536
} as const
3637

38+
/**
39+
* This body only ever carries an optional chatId/workspaceId string, so a
40+
* tight cap keeps an unauthenticated caller from forcing a large in-memory
41+
* allocation before the auth checks below run.
42+
*/
43+
const MAX_SPEECH_TOKEN_BODY_BYTES = 16 * 1024
44+
3745
function hashVoiceToken(token: string): string {
3846
return createHash('sha256').update(token).digest('hex')
3947
}
@@ -87,8 +95,9 @@ async function validateChatAuth(
8795

8896
export const POST = withRouteHandler(async (request: NextRequest) => {
8997
try {
90-
const rawBody = await request.json().catch(() => ({}))
91-
const body = speechTokenBodySchema.safeParse(rawBody)
98+
const parsedBody = await parseOptionalJsonBody(request, MAX_SPEECH_TOKEN_BODY_BYTES)
99+
if (!parsedBody.success) return parsedBody.response
100+
const body = speechTokenBodySchema.safeParse(parsedBody.data ?? {})
92101
const chatId =
93102
body.success && typeof body.data.chatId === 'string' ? body.data.chatId : undefined
94103

0 commit comments

Comments
 (0)