Skip to content

Commit 2f7d607

Browse files
authored
fix(uploads): close multipart storage-quota bypass via quota-exempt contexts (#5155)
The multipart endpoint accepted the quota-exempt public-asset contexts (og-images, profile-pictures, workspace-logos), which skip checkStorageQuota, letting any authenticated writer open arbitrarily large upload sessions that never count against their plan limit. These contexts have no large-file flow: their client hooks hard-cap uploads at 5MB (image-only) and the direct-upload strategy only uses multipart above 50MB, so they always route through the presigned endpoint. Remove them from ALLOWED_UPLOAD_CONTEXTS (joining logs) so every context the multipart endpoint serves is quota-enforced.
1 parent 83dc806 commit 2f7d607

3 files changed

Lines changed: 41 additions & 26 deletions

File tree

apps/sim/app/api/files/multipart/route.test.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ vi.mock('@/lib/uploads/core/upload-token', () => ({
3838

3939
vi.mock('@/lib/uploads/providers/s3/client', () => ({
4040
completeS3MultipartUpload: mockCompleteS3MultipartUpload,
41-
initiateS3MultipartUpload: vi.fn(),
41+
initiateS3MultipartUpload: mockInitiateS3MultipartUpload,
4242
getS3MultipartPartUrls: vi.fn(),
4343
abortS3MultipartUpload: vi.fn(),
4444
}))
@@ -247,31 +247,38 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => {
247247
expect(body.error).toContain('Storage limit exceeded')
248248
})
249249

250-
it('does not check quota for quota-exempt contexts (og-images)', async () => {
250+
it('allows quota-enforced contexts that pass the quota check', async () => {
251251
const res = await makeInitiateRequest({
252-
fileName: 'img.png',
253-
contentType: 'image/png',
252+
fileName: 'doc.pdf',
253+
contentType: 'application/pdf',
254254
fileSize: 99999,
255255
workspaceId: 'ws-1',
256-
context: 'og-images',
256+
context: 'knowledge-base',
257257
})
258258

259259
const response = await POST(res)
260-
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
260+
expect(response.status).toBe(200)
261+
expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 99999)
262+
expect(mockInitiateS3MultipartUpload).toHaveBeenCalled()
261263
})
262264

263-
it('rejects logs context — not allowed via the multipart endpoint', async () => {
264-
const res = await makeInitiateRequest({
265-
fileName: 'exec.log',
266-
contentType: 'text/plain',
267-
fileSize: 1000,
268-
workspaceId: 'ws-1',
269-
context: 'logs',
270-
})
265+
it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])(
266+
'rejects quota-exempt context %s — not allowed via the multipart endpoint',
267+
async (context) => {
268+
const res = await makeInitiateRequest({
269+
fileName: 'asset.png',
270+
contentType: 'image/png',
271+
fileSize: 100 * 1024 * 1024 * 1024,
272+
workspaceId: 'ws-1',
273+
context,
274+
})
271275

272-
const response = await POST(res)
273-
expect(response.status).toBe(400)
274-
const body = await response.json()
275-
expect(body.error).toMatch(/invalid storage context/i)
276-
})
276+
const response = await POST(res)
277+
expect(response.status).toBe(400)
278+
const body = await response.json()
279+
expect(body.error).toMatch(/invalid storage context/i)
280+
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
281+
expect(mockInitiateS3MultipartUpload).not.toHaveBeenCalled()
282+
}
283+
)
277284
})

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,22 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
3030

3131
const logger = createLogger('MultipartUploadAPI')
3232

33+
/**
34+
* Contexts the multipart endpoint accepts. The quota-exempt public-asset
35+
* contexts (`profile-pictures`, `workspace-logos`, `og-images`) and the
36+
* system-internal `logs` context are deliberately excluded: their uploads are
37+
* small images capped far below the multipart threshold and routed through the
38+
* presigned endpoint, so they have no large-file flow here. Accepting them would
39+
* only expose a path that bypasses the per-user storage quota, since every
40+
* context in this set is quota-enforced below.
41+
*/
3342
const ALLOWED_UPLOAD_CONTEXTS = new Set<StorageContext>([
3443
'knowledge-base',
3544
'chat',
3645
'copilot',
3746
'mothership',
3847
'execution',
3948
'workspace',
40-
'profile-pictures',
41-
'og-images',
42-
'workspace-logos',
4349
])
4450

4551
/**
@@ -159,7 +165,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
159165

160166
const config = getStorageConfig(storageContext)
161167

162-
if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(context as StorageContext)) {
168+
if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(storageContext)) {
163169
const { checkStorageQuota } = await import('@/lib/billing/storage')
164170
const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0)
165171
if (!quotaCheck.allowed) {

apps/sim/lib/uploads/shared/types.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ export type StorageContext =
2929
* metadata assets (`profile-pictures`, `workspace-logos`, `og-images`). All
3030
* other contexts are user-driven uploads and must pass quota validation.
3131
*
32-
* Note: `logs` is excluded from `ALLOWED_UPLOAD_CONTEXTS` in the multipart
33-
* endpoint, so it is unreachable there. The exemption applies to non-multipart
34-
* (single-part) upload paths used by the execution logging pipeline.
32+
* Note: every quota-exempt context is excluded from `ALLOWED_UPLOAD_CONTEXTS`
33+
* in the multipart endpoint, so none are reachable there — the exemption applies
34+
* only to the single-part upload paths (presigned/FormData) those small assets
35+
* actually use. The multipart endpoint therefore only ever serves
36+
* quota-enforced contexts.
3537
*/
3638
export const QUOTA_EXEMPT_STORAGE_CONTEXTS = new Set<StorageContext>([
3739
'profile-pictures',

0 commit comments

Comments
 (0)