From b4d0ed556afee5ae54c94dd276b21f70690848c3 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 4 Jul 2026 11:57:42 -0700 Subject: [PATCH 1/2] fix(stt): bound audio download response size Cap the audioUrl download in the STT proxy route at the platform's standard 100MB file-size ceiling, matching the pattern already used by sharepoint/download-file and other external download routes. Classify size-limit rejections as a clean 413 instead of an unhandled 500. --- apps/sim/app/api/tools/stt/route.test.ts | 115 +++++++++++++++++++++++ apps/sim/app/api/tools/stt/route.ts | 12 ++- 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/tools/stt/route.test.ts diff --git a/apps/sim/app/api/tools/stt/route.test.ts b/apps/sim/app/api/tools/stt/route.test.ts new file mode 100644 index 00000000000..3413350e8e5 --- /dev/null +++ b/apps/sim/app/api/tools/stt/route.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' + +const { mockIsInternalFileUrl, mockDownloadFileFromStorage, mockResolveInternalFileUrl } = + vi.hoisted(() => ({ + mockIsInternalFileUrl: vi.fn(), + mockDownloadFileFromStorage: vi.fn(), + mockResolveInternalFileUrl: vi.fn(), + })) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + isInternalFileUrl: mockIsInternalFileUrl, + getMimeTypeFromExtension: vi.fn(() => 'application/octet-stream'), +})) +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadFileFromStorage: mockDownloadFileFromStorage, + resolveInternalFileUrl: mockResolveInternalFileUrl, +})) +vi.mock('@/app/api/files/authorization', () => ({ + assertToolFileAccess: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/audio/extractor', () => ({ + isVideoFile: vi.fn(() => false), + extractAudioFromVideo: vi.fn(), +})) + +import { POST } from '@/app/api/tools/stt/route' + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + provider: 'whisper', + apiKey: 'test-api-key', + audioUrl: 'https://example.com/audio.mp3', +} + +function mockSecureFetchResponse(body: { ok?: boolean; contentType?: string }) { + return { + ok: body.ok ?? true, + status: 200, + statusText: '', + headers: new Headers({ 'content-type': body.contentType ?? 'audio/mpeg' }), + body: null, + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(8), + } +} + +describe('POST /api/tools/stt', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'example.com', + }) + mockIsInternalFileUrl.mockReturnValue(false) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ text: 'hello world', language: 'en', duration: 1.2 }), + }) + ) + }) + + it('bounds the audioUrl download and rejects oversized responses cleanly', async () => { + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: 100 * 1024 * 1024, + observedBytes: 200 * 1024 * 1024, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + + expect(response.status).toBe(413) + const data = (await response.json()) as { error: string } + expect(data.error).toMatch(/exceeds the maximum supported size/i) + + const call = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0] + expect(call[1]).toBe(PINNED_IP) + expect(call[2]).toMatchObject({ maxResponseBytes: 100 * 1024 * 1024 }) + }) + + it('transcribes a normal, well-under-cap audio download successfully', async () => { + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + mockSecureFetchResponse({}) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + + expect(response.status).toBe(200) + const data = (await response.json()) as { transcript: string } + expect(data.transcript).toBe('hello world') + }) +}) diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index fd9a6dc12d7..a224ba642dd 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -12,12 +12,14 @@ import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getMimeTypeFromExtension, isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import { assertToolFileAccess } from '@/app/api/files/authorization' import type { TranscriptSegment } from '@/tools/stt/types' @@ -150,6 +152,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const response = await secureFetchWithPinnedIP(audioUrl, urlValidation.resolvedIP!, { method: 'GET', + maxResponseBytes: MAX_FILE_SIZE, }) if (!response.ok) { await response.text().catch(() => {}) @@ -297,8 +300,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response) } catch (error) { logger.error(`[${requestId}] STT proxy error:`, error) - const errorMessage = getErrorMessage(error, 'Unknown error') - return NextResponse.json({ error: errorMessage }, { status: 500 }) + const errorMessage = isPayloadSizeLimitError(error) + ? 'Audio file exceeds the maximum supported size' + : getErrorMessage(error, 'Unknown error') + return NextResponse.json( + { error: errorMessage }, + { status: isPayloadSizeLimitError(error) ? 413 : 500 } + ) } }) From 560270730b2663d95cb4647ec0a35284df6fe2ed Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 4 Jul 2026 12:11:01 -0700 Subject: [PATCH 2/2] fix(stt): avoid double isPayloadSizeLimitError call in error handling --- apps/sim/app/api/tools/stt/route.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index a224ba642dd..e245d498a91 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -300,13 +300,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response) } catch (error) { logger.error(`[${requestId}] STT proxy error:`, error) - const errorMessage = isPayloadSizeLimitError(error) + const isSizeLimit = isPayloadSizeLimitError(error) + const errorMessage = isSizeLimit ? 'Audio file exceeds the maximum supported size' : getErrorMessage(error, 'Unknown error') - return NextResponse.json( - { error: errorMessage }, - { status: isPayloadSizeLimitError(error) ? 413 : 500 } - ) + return NextResponse.json({ error: errorMessage }, { status: isSizeLimit ? 413 : 500 }) } })