From e8ffb1516b828ce77f6232758746f8e8ae94ea4e Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 13:56:43 -0700 Subject: [PATCH] fix(uploads): close multipart storage-quota bypass via quota-exempt contexts 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. --- .../sim/app/api/files/multipart/route.test.ts | 45 +++++++++++-------- apps/sim/app/api/files/multipart/route.ts | 14 ++++-- apps/sim/lib/uploads/shared/types.ts | 8 ++-- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/api/files/multipart/route.test.ts b/apps/sim/app/api/files/multipart/route.test.ts index 520a05dd065..798f86303ef 100644 --- a/apps/sim/app/api/files/multipart/route.test.ts +++ b/apps/sim/app/api/files/multipart/route.test.ts @@ -38,7 +38,7 @@ vi.mock('@/lib/uploads/core/upload-token', () => ({ vi.mock('@/lib/uploads/providers/s3/client', () => ({ completeS3MultipartUpload: mockCompleteS3MultipartUpload, - initiateS3MultipartUpload: vi.fn(), + initiateS3MultipartUpload: mockInitiateS3MultipartUpload, getS3MultipartPartUrls: vi.fn(), abortS3MultipartUpload: vi.fn(), })) @@ -247,31 +247,38 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => { expect(body.error).toContain('Storage limit exceeded') }) - it('does not check quota for quota-exempt contexts (og-images)', async () => { + it('allows quota-enforced contexts that pass the quota check', async () => { const res = await makeInitiateRequest({ - fileName: 'img.png', - contentType: 'image/png', + fileName: 'doc.pdf', + contentType: 'application/pdf', fileSize: 99999, workspaceId: 'ws-1', - context: 'og-images', + context: 'knowledge-base', }) const response = await POST(res) - expect(mockCheckStorageQuota).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 99999) + expect(mockInitiateS3MultipartUpload).toHaveBeenCalled() }) - it('rejects logs context — not allowed via the multipart endpoint', async () => { - const res = await makeInitiateRequest({ - fileName: 'exec.log', - contentType: 'text/plain', - fileSize: 1000, - workspaceId: 'ws-1', - context: 'logs', - }) + it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])( + 'rejects quota-exempt context %s — not allowed via the multipart endpoint', + async (context) => { + const res = await makeInitiateRequest({ + fileName: 'asset.png', + contentType: 'image/png', + fileSize: 100 * 1024 * 1024 * 1024, + workspaceId: 'ws-1', + context, + }) - const response = await POST(res) - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toMatch(/invalid storage context/i) - }) + const response = await POST(res) + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toMatch(/invalid storage context/i) + expect(mockCheckStorageQuota).not.toHaveBeenCalled() + expect(mockInitiateS3MultipartUpload).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index fbdb4e4016a..1e570c3b9a9 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -30,6 +30,15 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MultipartUploadAPI') +/** + * Contexts the multipart endpoint accepts. The quota-exempt public-asset + * contexts (`profile-pictures`, `workspace-logos`, `og-images`) and the + * system-internal `logs` context are deliberately excluded: their uploads are + * small images capped far below the multipart threshold and routed through the + * presigned endpoint, so they have no large-file flow here. Accepting them would + * only expose a path that bypasses the per-user storage quota, since every + * context in this set is quota-enforced below. + */ const ALLOWED_UPLOAD_CONTEXTS = new Set([ 'knowledge-base', 'chat', @@ -37,9 +46,6 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set([ 'mothership', 'execution', 'workspace', - 'profile-pictures', - 'og-images', - 'workspace-logos', ]) /** @@ -159,7 +165,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const config = getStorageConfig(storageContext) - if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(context as StorageContext)) { + if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(storageContext)) { const { checkStorageQuota } = await import('@/lib/billing/storage') const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0) if (!quotaCheck.allowed) { diff --git a/apps/sim/lib/uploads/shared/types.ts b/apps/sim/lib/uploads/shared/types.ts index 827c08b0f7a..8998f77cd1f 100644 --- a/apps/sim/lib/uploads/shared/types.ts +++ b/apps/sim/lib/uploads/shared/types.ts @@ -29,9 +29,11 @@ export type StorageContext = * metadata assets (`profile-pictures`, `workspace-logos`, `og-images`). All * other contexts are user-driven uploads and must pass quota validation. * - * Note: `logs` is excluded from `ALLOWED_UPLOAD_CONTEXTS` in the multipart - * endpoint, so it is unreachable there. The exemption applies to non-multipart - * (single-part) upload paths used by the execution logging pipeline. + * Note: every quota-exempt context is excluded from `ALLOWED_UPLOAD_CONTEXTS` + * in the multipart endpoint, so none are reachable there — the exemption applies + * only to the single-part upload paths (presigned/FormData) those small assets + * actually use. The multipart endpoint therefore only ever serves + * quota-enforced contexts. */ export const QUOTA_EXEMPT_STORAGE_CONTEXTS = new Set([ 'profile-pictures',