mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user