From 5d331fb8cac707edf97d73629eea565a4052f715 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 4 Jul 2026 12:03:09 -0700 Subject: [PATCH] fix(tools): bound download response size for drive/slack/onedrive Google Drive, Slack, and OneDrive download routes fetched provider file content without a response size cap, unlike the SharePoint download route. Add maxResponseBytes to each content fetch, reject Google Drive files whose metadata size already exceeds the cap before starting the download, and map the resulting size-limit error to a clean 413 response. --- .../tools/google_drive/download/route.test.ts | 154 ++++++++++++++++++ .../api/tools/google_drive/download/route.ts | 16 +- .../api/tools/onedrive/download/route.test.ts | 107 ++++++++++++ .../app/api/tools/onedrive/download/route.ts | 5 +- .../api/tools/slack/download/route.test.ts | 99 +++++++++++ .../sim/app/api/tools/slack/download/route.ts | 5 +- 6 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/api/tools/google_drive/download/route.test.ts create mode 100644 apps/sim/app/api/tools/onedrive/download/route.test.ts create mode 100644 apps/sim/app/api/tools/slack/download/route.test.ts diff --git a/apps/sim/app/api/tools/google_drive/download/route.test.ts b/apps/sim/app/api/tools/google_drive/download/route.test.ts new file mode 100644 index 00000000000..2599b515a3b --- /dev/null +++ b/apps/sim/app/api/tools/google_drive/download/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { POST } from '@/app/api/tools/google_drive/download/route' + +const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + accessToken: 'token-123', + fileId: 'file-abc', +} + +function jsonResponse(body: unknown, ok = true) { + return { + ok, + status: ok ? 200 : 400, + statusText: '', + headers: new Headers(), + body: null, + text: async () => JSON.stringify(body), + json: async () => body, + arrayBuffer: async () => new ArrayBuffer(0), + } +} + +function fileResponse(bytes: number, ok = true) { + return { + ok, + status: ok ? 200 : 400, + statusText: '', + headers: new Headers(), + body: null, + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(bytes), + } +} + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'www.googleapis.com', + }) +}) + +describe('POST /api/tools/google_drive/download', () => { + it('downloads a normal file under the size cap', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + id: 'file-abc', + name: 'report.pdf', + mimeType: 'application/pdf', + size: '1024', + capabilities: { canReadRevisions: false }, + }) + ) + .mockResolvedValueOnce(fileResponse(1024)) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + const data = (await response.json()) as { success: boolean; output: { file: { size: number } } } + expect(data.success).toBe(true) + expect(data.output.file.size).toBe(1024) + + const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1] + expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE }) + }) + + it('rejects the download before fetching content when metadata size exceeds the cap', async () => { + mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + jsonResponse({ + id: 'file-abc', + name: 'huge.bin', + mimeType: 'application/octet-stream', + size: String(MAX_FILE_SIZE + 1), + capabilities: { canReadRevisions: false }, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(413) + const data = (await response.json()) as { success: boolean; error: string } + expect(data.success).toBe(false) + expect(data.error).toContain('exceeds maximum size') + + // Content download must never be initiated once metadata size trips the check. + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1) + }) + + it('surfaces a clean 413 when the streamed content exceeds the cap', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + id: 'file-abc', + name: 'report.pdf', + mimeType: 'application/pdf', + capabilities: { canReadRevisions: false }, + }) + ) + .mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: MAX_FILE_SIZE, + observedBytes: MAX_FILE_SIZE + 1, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(413) + const data = (await response.json()) as { success: boolean } + expect(data.success).toBe(false) + }) + + it('does not require a metadata size for Google Workspace exports', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + id: 'doc-1', + name: 'My Doc', + mimeType: 'application/vnd.google-apps.document', + capabilities: { canReadRevisions: false }, + }) + ) + .mockResolvedValueOnce(fileResponse(2048)) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + + const exportCall = mockSecureFetchWithPinnedIP.mock.calls[1] + expect(exportCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE }) + }) +}) diff --git a/apps/sim/app/api/tools/google_drive/download/route.ts b/apps/sim/app/api/tools/google_drive/download/route.ts index 8d063b8058c..a4e76f2acd6 100644 --- a/apps/sim/app/api/tools/google_drive/download/route.ts +++ b/apps/sim/app/api/tools/google_drive/download/route.ts @@ -9,7 +9,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import type { GoogleDriveFile, GoogleDriveRevision } from '@/tools/google_drive/types' import { ALL_FILE_FIELDS, @@ -160,7 +162,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const exportResponse = await secureFetchWithPinnedIP( exportUrl, exportUrlValidation.resolvedIP!, - { headers: { Authorization: authHeader } } + { headers: { Authorization: authHeader }, maxResponseBytes: MAX_FILE_SIZE } ) if (!exportResponse.ok) { @@ -185,6 +187,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else { logger.info(`[${requestId}] Downloading regular file`, { fileId, mimeType: fileMimeType }) + if (metadata.size) { + assertKnownSizeWithinLimit( + Number.parseInt(metadata.size, 10), + MAX_FILE_SIZE, + `Google Drive file ${fileId}` + ) + } + const downloadUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&supportsAllDrives=true` const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl') if (!downloadUrlValidation.isValid) { @@ -197,7 +207,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const downloadResponse = await secureFetchWithPinnedIP( downloadUrl, downloadUrlValidation.resolvedIP!, - { headers: { Authorization: authHeader } } + { headers: { Authorization: authHeader }, maxResponseBytes: MAX_FILE_SIZE } ) if (!downloadResponse.ok) { @@ -274,7 +284,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: false, error: getErrorMessage(error, 'Unknown error occurred'), }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } }) diff --git a/apps/sim/app/api/tools/onedrive/download/route.test.ts b/apps/sim/app/api/tools/onedrive/download/route.test.ts new file mode 100644 index 00000000000..ea93ea79eba --- /dev/null +++ b/apps/sim/app/api/tools/onedrive/download/route.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { POST } from '@/app/api/tools/onedrive/download/route' + +const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + accessToken: 'token-123', + fileId: 'file-abc', +} + +function jsonResponse(body: unknown, ok = true) { + return { + ok, + status: ok ? 200 : 400, + statusText: '', + headers: new Headers(), + body: null, + text: async () => JSON.stringify(body), + json: async () => body, + arrayBuffer: async () => new ArrayBuffer(0), + } +} + +function fileResponse(bytes: number) { + return { + ok: true, + status: 200, + statusText: '', + headers: new Headers(), + body: null, + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(bytes), + } +} + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'graph.microsoft.com', + }) +}) + +describe('POST /api/tools/onedrive/download', () => { + it('downloads a normal file under the size cap', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ id: 'file-abc', name: 'report.pdf', file: { mimeType: 'application/pdf' } }) + ) + .mockResolvedValueOnce(fileResponse(1024)) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + const data = (await response.json()) as { success: boolean; output: { file: { size: number } } } + expect(data.success).toBe(true) + expect(data.output.file.size).toBe(1024) + + const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1] + expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE }) + }) + + it('surfaces a clean 413 when the streamed content exceeds the cap', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + id: 'file-abc', + name: 'huge.bin', + file: { mimeType: 'application/octet-stream' }, + }) + ) + .mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: MAX_FILE_SIZE, + observedBytes: MAX_FILE_SIZE + 1, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(413) + const data = (await response.json()) as { success: boolean } + expect(data.success).toBe(false) + }) +}) diff --git a/apps/sim/app/api/tools/onedrive/download/route.ts b/apps/sim/app/api/tools/onedrive/download/route.ts index d713208c494..d1c314397c3 100644 --- a/apps/sim/app/api/tools/onedrive/download/route.ts +++ b/apps/sim/app/api/tools/onedrive/download/route.ts @@ -9,7 +9,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' export const dynamic = 'force-dynamic' @@ -120,6 +122,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { downloadUrlValidation.resolvedIP!, { headers: { Authorization: authHeader }, + maxResponseBytes: MAX_FILE_SIZE, } ) @@ -167,7 +170,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: false, error: getErrorMessage(error, 'Unknown error occurred'), }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } }) diff --git a/apps/sim/app/api/tools/slack/download/route.test.ts b/apps/sim/app/api/tools/slack/download/route.test.ts new file mode 100644 index 00000000000..8e31d5fcc53 --- /dev/null +++ b/apps/sim/app/api/tools/slack/download/route.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { POST } from '@/app/api/tools/slack/download/route' + +const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + accessToken: 'token-123', + fileId: 'file-abc', +} + +function fileResponse(bytes: number) { + return { + ok: true, + status: 200, + statusText: '', + headers: new Headers(), + body: null, + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(bytes), + } +} + +const originalFetch = global.fetch + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'files.slack.com', + }) + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + file: { + name: 'report.pdf', + mimetype: 'application/pdf', + url_private: 'https://files.slack.com/files-pri/T000-F000/report.pdf', + }, + }), + }) as unknown as typeof fetch +}) + +afterEach(() => { + global.fetch = originalFetch +}) + +describe('POST /api/tools/slack/download', () => { + it('downloads a normal file under the size cap', async () => { + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(fileResponse(1024)) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + const data = (await response.json()) as { success: boolean; output: { file: { size: number } } } + expect(data.success).toBe(true) + expect(data.output.file.size).toBe(1024) + + const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[0] + expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE }) + }) + + it('surfaces a clean 413 when the streamed content exceeds the cap', async () => { + mockSecureFetchWithPinnedIP.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: MAX_FILE_SIZE, + observedBytes: MAX_FILE_SIZE + 1, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(413) + const data = (await response.json()) as { success: boolean } + expect(data.success).toBe(false) + }) +}) diff --git a/apps/sim/app/api/tools/slack/download/route.ts b/apps/sim/app/api/tools/slack/download/route.ts index 2cc356bef6d..68eef0e7048 100644 --- a/apps/sim/app/api/tools/slack/download/route.ts +++ b/apps/sim/app/api/tools/slack/download/route.ts @@ -9,7 +9,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' export const dynamic = 'force-dynamic' @@ -114,6 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { headers: { Authorization: `Bearer ${accessToken}`, }, + maxResponseBytes: MAX_FILE_SIZE, }) if (!downloadResponse.ok) { @@ -160,7 +163,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: false, error: getErrorMessage(error, 'Unknown error occurred'), }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } })