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 } ) } })