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.
This commit is contained in:
Waleed
2026-07-11 14:14:11 -07:00
committed by GitHub
parent b486aba07e
commit e2e29eed30
4 changed files with 53 additions and 7 deletions
+25 -1
View File
@@ -5,12 +5,26 @@ import { helpFormBodySchema } from '@/lib/api/contracts/common'
import { validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import {
isPayloadSizeLimitError,
MAX_MULTIPART_OVERHEAD_BYTES,
readFormDataWithLimit,
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getHelpEmailAddress } from '@/lib/messaging/email/utils'
import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
const logger = createLogger('HelpAPI')
/**
* The form can carry several image attachments with no server-side count
* cap, so this reuses the repo's largest existing per-request form-data
* bound (see files/upload route) rather than an arbitrary smaller limit
* that could reject a legitimate multi-image submission.
*/
const MAX_HELP_FORM_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES
export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
@@ -23,7 +37,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const email = session.user.email
const formData = await req.formData()
const formData = await readFormDataWithLimit(req, {
maxBytes: MAX_HELP_FORM_BYTES,
label: 'Help request form data',
})
const subject = formData.get('subject') as string
const message = formData.get('message') as string
@@ -128,6 +145,13 @@ ${message}
{ status: 200 }
)
} catch (error) {
if (isPayloadSizeLimitError(error)) {
logger.warn(`[${requestId}] Help request form data too large`, { message: error.message })
return NextResponse.json(
{ error: `Request body exceeds the maximum allowed size of ${MAX_HELP_FORM_BYTES} bytes` },
{ status: 413 }
)
}
if (error instanceof Error && error.message.includes('not configured')) {
logger.error(`[${requestId}] Email service configuration error`, error)
return NextResponse.json(
@@ -7,7 +7,7 @@ import {
createChunkBodySchema,
listKnowledgeChunksQuerySchema,
} from '@/lib/api/contracts/knowledge'
import { isZodError, parseRequest } from '@/lib/api/server'
import { isZodError, parseJsonBody, parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -103,9 +103,6 @@ export const POST = withRouteHandler(
const { id: knowledgeBaseId, documentId } = await params
try {
const body = await req.json()
const { workflowId, ...searchParams } = body
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`)
@@ -113,7 +110,14 @@ export const POST = withRouteHandler(
}
const userId = auth.userId
const parsedBody = await parseJsonBody(req)
if (!parsedBody.success) return parsedBody.response
const { workflowId, ...searchParams } = parsedBody.data as Record<string, unknown>
if (workflowId) {
if (typeof workflowId !== 'string') {
return NextResponse.json({ error: 'workflowId must be a string' }, { status: 400 })
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
@@ -138,4 +138,13 @@ describe('POST /api/speech/token — usage attribution', () => {
workspaceId: 'ws-1',
})
})
it('rejects an oversized body before any auth/billing work runs', async () => {
const oversizedBody = { chatId: 'x'.repeat(64 * 1024) }
const res = await POST(createMockRequest('POST', oversizedBody))
expect(res.status).toBe(413)
expect(mockGetSession).not.toHaveBeenCalled()
expect(mockRecordUsage).not.toHaveBeenCalled()
})
})
+11 -2
View File
@@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { speechTokenBodySchema } from '@/lib/api/contracts/media/speech'
import { parseOptionalJsonBody } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { recordUsage } from '@/lib/billing/core/usage-log'
@@ -34,6 +35,13 @@ const STT_TOKEN_RATE_LIMIT = {
refillIntervalMs: 72 * 1000,
} as const
/**
* This body only ever carries an optional chatId/workspaceId string, so a
* tight cap keeps an unauthenticated caller from forcing a large in-memory
* allocation before the auth checks below run.
*/
const MAX_SPEECH_TOKEN_BODY_BYTES = 16 * 1024
function hashVoiceToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
@@ -87,8 +95,9 @@ async function validateChatAuth(
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const rawBody = await request.json().catch(() => ({}))
const body = speechTokenBodySchema.safeParse(rawBody)
const parsedBody = await parseOptionalJsonBody(request, MAX_SPEECH_TOKEN_BODY_BYTES)
if (!parsedBody.success) return parsedBody.response
const body = speechTokenBodySchema.safeParse(parsedBody.data ?? {})
const chatId =
body.success && typeof body.data.chatId === 'string' ? body.data.chatId : undefined