Skip to content

Commit 1a371e5

Browse files
authored
fix(uploads): bound multipart body read in workspace-file and knowledge-document upload routes (#5413)
* fix(uploads): bound multipart body read in workspace-file and knowledge-document upload routes * fix(uploads): avoid FormData-body stream race in bounded-read tests * fix(uploads): share MAX_MULTIPART_OVERHEAD_BYTES constant across upload routes
1 parent b98c7c4 commit 1a371e5

7 files changed

Lines changed: 352 additions & 5 deletions

File tree

apps/sim/app/api/files/upload/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { getSession } from '@/lib/auth'
1313
import {
1414
assertKnownSizeWithinLimit,
1515
isPayloadSizeLimitError,
16+
MAX_MULTIPART_OVERHEAD_BYTES,
1617
readFileToBufferWithLimit,
1718
readFormDataWithLimit,
1819
} from '@/lib/core/utils/stream-limits'
@@ -32,7 +33,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
3233
import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils'
3334

3435
const ALLOWED_EXTENSIONS = new Set<string>(SUPPORTED_ATTACHMENT_EXTENSIONS)
35-
const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024
3636

3737
function validateFileExtension(filename: string): boolean {
3838
const extension = filename.split('.').pop()?.toLowerCase()

apps/sim/app/api/v1/files/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import {
99
isPayloadSizeLimitError,
10+
MAX_MULTIPART_OVERHEAD_BYTES,
1011
readFileToBufferWithLimit,
1112
readFormDataWithLimit,
1213
} from '@/lib/core/utils/stream-limits'
@@ -30,7 +31,6 @@ export const dynamic = 'force-dynamic'
3031
export const revalidate = 0
3132

3233
const MAX_FILE_SIZE = 100 * 1024 * 1024
33-
const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024
3434

3535
/** GET /api/v1/files — List all files in a workspace. */
3636
export const GET = withRouteHandler(async (request: NextRequest) => {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Tests for the v1 knowledge document upload route's bounded multipart read.
3+
*
4+
* @vitest-environment node
5+
*/
6+
import { NextRequest } from 'next/server'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const {
10+
mockAuthenticateRequest,
11+
mockResolveKnowledgeBase,
12+
mockCheckActorUsageLimits,
13+
mockUploadWorkspaceFile,
14+
mockCreateSingleDocument,
15+
mockProcessDocumentsWithQueue,
16+
mockValidateFileType,
17+
} = vi.hoisted(() => ({
18+
mockAuthenticateRequest: vi.fn(),
19+
mockResolveKnowledgeBase: vi.fn(),
20+
mockCheckActorUsageLimits: vi.fn(),
21+
mockUploadWorkspaceFile: vi.fn(),
22+
mockCreateSingleDocument: vi.fn(),
23+
mockProcessDocumentsWithQueue: vi.fn(),
24+
mockValidateFileType: vi.fn(),
25+
}))
26+
27+
vi.mock('@/app/api/v1/middleware', () => ({
28+
authenticateRequest: mockAuthenticateRequest,
29+
}))
30+
31+
vi.mock('@/app/api/v1/knowledge/utils', () => ({
32+
resolveKnowledgeBase: mockResolveKnowledgeBase,
33+
serializeDate: (date: unknown) => (date instanceof Date ? date.toISOString() : date),
34+
handleError: (_requestId: string, error: unknown) =>
35+
new Response(JSON.stringify({ error: error instanceof Error ? error.message : 'error' }), {
36+
status: 500,
37+
}),
38+
}))
39+
40+
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
41+
checkActorUsageLimits: mockCheckActorUsageLimits,
42+
}))
43+
44+
vi.mock('@/lib/uploads/contexts/workspace', () => ({
45+
uploadWorkspaceFile: mockUploadWorkspaceFile,
46+
}))
47+
48+
vi.mock('@/lib/uploads/utils/validation', () => ({
49+
validateFileType: mockValidateFileType,
50+
}))
51+
52+
vi.mock('@/lib/knowledge/documents/service', () => ({
53+
createSingleDocument: mockCreateSingleDocument,
54+
getDocuments: vi.fn(),
55+
processDocumentsWithQueue: mockProcessDocumentsWithQueue,
56+
}))
57+
58+
import { POST } from '@/app/api/v1/knowledge/[id]/documents/route'
59+
60+
const routeContext = { params: Promise.resolve({ id: 'kb-1' }) }
61+
62+
function buildFormData(file: File, workspaceId = 'ws-1'): FormData {
63+
const formData = new FormData()
64+
formData.append('workspaceId', workspaceId)
65+
formData.append('file', file)
66+
return formData
67+
}
68+
69+
/**
70+
* Builds a pull-based stream that emits fixed-size chunks on demand, so the
71+
* size-capped reader's `reader.cancel()` simply stops future `pull` calls
72+
* instead of racing an external (e.g. undici FormData) chunk producer.
73+
*/
74+
function makeChunkedOverLimitBody(
75+
chunkBytes: number,
76+
chunkCount: number
77+
): ReadableStream<Uint8Array> {
78+
let emitted = 0
79+
return new ReadableStream<Uint8Array>({
80+
pull(controller) {
81+
if (emitted >= chunkCount) {
82+
controller.close()
83+
return
84+
}
85+
emitted++
86+
controller.enqueue(new Uint8Array(chunkBytes))
87+
},
88+
})
89+
}
90+
91+
describe('v1 knowledge document upload route', () => {
92+
beforeEach(() => {
93+
vi.clearAllMocks()
94+
mockAuthenticateRequest.mockResolvedValue({
95+
requestId: 'req-1',
96+
userId: 'user-1',
97+
rateLimit: {},
98+
})
99+
mockResolveKnowledgeBase.mockResolvedValue({ kb: { id: 'kb-1', workspaceId: 'ws-1' } })
100+
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
101+
mockValidateFileType.mockReturnValue(null)
102+
mockUploadWorkspaceFile.mockResolvedValue({
103+
url: 'https://example.com/file.txt',
104+
})
105+
mockCreateSingleDocument.mockResolvedValue({
106+
id: 'doc-1',
107+
filename: 'file.txt',
108+
fileSize: 100,
109+
mimeType: 'text/plain',
110+
enabled: true,
111+
uploadedAt: new Date('2026-01-01T00:00:00.000Z'),
112+
})
113+
mockProcessDocumentsWithQueue.mockResolvedValue(undefined)
114+
})
115+
116+
it('rejects a declared content-length above the limit before reading the body', async () => {
117+
const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' }))
118+
const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
119+
method: 'POST',
120+
headers: { 'content-length': String(200 * 1024 * 1024) },
121+
body: formData,
122+
})
123+
124+
const response = await POST(req, routeContext)
125+
const data = await response.json()
126+
127+
expect(response.status).toBe(413)
128+
expect(data.error).toContain('exceeds maximum size')
129+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
130+
})
131+
132+
it('rejects a chunked body without content-length once the streamed size trips the cap', async () => {
133+
const body = makeChunkedOverLimitBody(1024 * 1024, 200)
134+
const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
135+
method: 'POST',
136+
body,
137+
// @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types
138+
duplex: 'half',
139+
})
140+
expect(req.headers.get('content-length')).toBeNull()
141+
142+
const response = await POST(req, routeContext)
143+
const data = await response.json()
144+
145+
expect(response.status).toBe(413)
146+
expect(data.error).toContain('exceeds maximum size')
147+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
148+
})
149+
150+
it('uploads a normal, well-under-limit document successfully', async () => {
151+
const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
152+
const formData = buildFormData(file)
153+
const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
154+
method: 'POST',
155+
headers: { 'content-length': '1024' },
156+
body: formData,
157+
})
158+
159+
const response = await POST(req, routeContext)
160+
const data = await response.json()
161+
162+
expect(response.status).toBe(200)
163+
expect(data.success).toBe(true)
164+
expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
165+
expect(mockCreateSingleDocument).toHaveBeenCalledTimes(1)
166+
})
167+
})

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import {
66
} from '@/lib/api/contracts/v1/knowledge'
77
import { parseRequest } from '@/lib/api/server'
88
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
9+
import {
10+
isPayloadSizeLimitError,
11+
MAX_MULTIPART_OVERHEAD_BYTES,
12+
readFormDataWithLimit,
13+
} from '@/lib/core/utils/stream-limits'
914
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1015
import {
1116
createSingleDocument,
@@ -96,8 +101,14 @@ export const POST = withRouteHandler(
96101

97102
let formData: FormData
98103
try {
99-
formData = await request.formData()
100-
} catch {
104+
formData = await readFormDataWithLimit(request, {
105+
maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
106+
label: 'knowledge document upload body',
107+
})
108+
} catch (error) {
109+
if (isPayloadSizeLimitError(error)) {
110+
return NextResponse.json({ error: error.message }, { status: 413 })
111+
}
101112
return NextResponse.json(
102113
{ error: 'Request body must be valid multipart form data' },
103114
{ status: 400 }
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Tests for the workspace files upload route's bounded multipart read.
3+
*
4+
* @vitest-environment node
5+
*/
6+
import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing'
7+
import { NextRequest } from 'next/server'
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
const { mockUploadWorkspaceFile, mockGetSharesForResources, mockRecordAudit } = vi.hoisted(() => ({
11+
mockUploadWorkspaceFile: vi.fn(),
12+
mockGetSharesForResources: vi.fn(),
13+
mockRecordAudit: vi.fn(),
14+
}))
15+
16+
vi.mock('@/lib/uploads/contexts/workspace', () => ({
17+
uploadWorkspaceFile: mockUploadWorkspaceFile,
18+
FileConflictError: class FileConflictError extends Error {},
19+
}))
20+
21+
vi.mock('@/lib/uploads/shared/types', async (importOriginal) => {
22+
const actual = await importOriginal<typeof import('@/lib/uploads/shared/types')>()
23+
return {
24+
...actual,
25+
MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024,
26+
}
27+
})
28+
29+
vi.mock('@/lib/public-shares/share-manager', () => ({
30+
getSharesForResources: mockGetSharesForResources,
31+
}))
32+
33+
vi.mock('@/lib/posthog/server', () => posthogServerMock)
34+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
35+
vi.mock('@/app/api/workflows/utils', () => ({
36+
verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'),
37+
}))
38+
vi.mock('@sim/audit', () => ({
39+
recordAudit: mockRecordAudit,
40+
AuditAction: { FILE_UPLOADED: 'file_uploaded' },
41+
AuditResourceType: { FILE: 'file' },
42+
}))
43+
44+
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
45+
46+
import { POST } from '@/app/api/workspaces/[id]/files/route'
47+
48+
const routeContext = { params: Promise.resolve({ id: WS }) }
49+
50+
function buildFormData(file: File): FormData {
51+
const formData = new FormData()
52+
formData.append('file', file)
53+
return formData
54+
}
55+
56+
/**
57+
* Builds a pull-based stream that emits fixed-size chunks on demand, so the
58+
* size-capped reader's `reader.cancel()` simply stops future `pull` calls
59+
* instead of racing an external (e.g. undici FormData) chunk producer.
60+
*/
61+
function makeChunkedOverLimitBody(
62+
chunkBytes: number,
63+
chunkCount: number
64+
): ReadableStream<Uint8Array> {
65+
let emitted = 0
66+
return new ReadableStream<Uint8Array>({
67+
pull(controller) {
68+
if (emitted >= chunkCount) {
69+
controller.close()
70+
return
71+
}
72+
emitted++
73+
controller.enqueue(new Uint8Array(chunkBytes))
74+
},
75+
})
76+
}
77+
78+
describe('workspace files upload route', () => {
79+
beforeEach(() => {
80+
vi.clearAllMocks()
81+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
82+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
83+
mockGetSharesForResources.mockResolvedValue(new Map())
84+
mockUploadWorkspaceFile.mockResolvedValue({
85+
id: 'file-1',
86+
name: 'file.txt',
87+
url: 'https://example.com/file.txt',
88+
size: 11,
89+
type: 'text/plain',
90+
})
91+
})
92+
93+
it('rejects a declared content-length above the limit before reading the body', async () => {
94+
const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' }))
95+
const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
96+
method: 'POST',
97+
headers: { 'content-length': String(10 * 1024 * 1024) },
98+
body: formData,
99+
})
100+
101+
const response = await POST(req, routeContext)
102+
const data = await response.json()
103+
104+
expect(response.status).toBe(413)
105+
expect(data.error).toContain('exceeds maximum size')
106+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
107+
})
108+
109+
it('rejects a chunked body without content-length once the streamed size trips the cap', async () => {
110+
const body = makeChunkedOverLimitBody(64 * 1024, 32)
111+
const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
112+
method: 'POST',
113+
body,
114+
// @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types
115+
duplex: 'half',
116+
})
117+
expect(req.headers.get('content-length')).toBeNull()
118+
119+
const response = await POST(req, routeContext)
120+
const data = await response.json()
121+
122+
expect(response.status).toBe(413)
123+
expect(data.error).toContain('exceeds maximum size')
124+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
125+
})
126+
127+
it('uploads a normal, well-under-limit file successfully', async () => {
128+
const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
129+
const formData = buildFormData(file)
130+
const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, {
131+
method: 'POST',
132+
headers: { 'content-length': '512' },
133+
body: formData,
134+
})
135+
136+
const response = await POST(req, routeContext)
137+
const data = await response.json()
138+
139+
expect(response.status).toBe(200)
140+
expect(data.success).toBe(true)
141+
expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
142+
})
143+
})

0 commit comments

Comments
 (0)