Skip to content

Commit b4d0ed5

Browse files
committed
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.
1 parent 0249516 commit b4d0ed5

2 files changed

Lines changed: 125 additions & 2 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
createMockRequest,
6+
hybridAuthMockFns,
7+
inputValidationMock,
8+
inputValidationMockFns,
9+
} from '@sim/testing'
10+
import { beforeEach, describe, expect, it, vi } from 'vitest'
11+
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
12+
13+
const { mockIsInternalFileUrl, mockDownloadFileFromStorage, mockResolveInternalFileUrl } =
14+
vi.hoisted(() => ({
15+
mockIsInternalFileUrl: vi.fn(),
16+
mockDownloadFileFromStorage: vi.fn(),
17+
mockResolveInternalFileUrl: vi.fn(),
18+
}))
19+
20+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
21+
vi.mock('@/lib/uploads/utils/file-utils', () => ({
22+
isInternalFileUrl: mockIsInternalFileUrl,
23+
getMimeTypeFromExtension: vi.fn(() => 'application/octet-stream'),
24+
}))
25+
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
26+
downloadFileFromStorage: mockDownloadFileFromStorage,
27+
resolveInternalFileUrl: mockResolveInternalFileUrl,
28+
}))
29+
vi.mock('@/app/api/files/authorization', () => ({
30+
assertToolFileAccess: vi.fn().mockResolvedValue(null),
31+
}))
32+
vi.mock('@/lib/audio/extractor', () => ({
33+
isVideoFile: vi.fn(() => false),
34+
extractAudioFromVideo: vi.fn(),
35+
}))
36+
37+
import { POST } from '@/app/api/tools/stt/route'
38+
39+
const PINNED_IP = '93.184.216.34'
40+
41+
const baseBody = {
42+
provider: 'whisper',
43+
apiKey: 'test-api-key',
44+
audioUrl: 'https://example.com/audio.mp3',
45+
}
46+
47+
function mockSecureFetchResponse(body: { ok?: boolean; contentType?: string }) {
48+
return {
49+
ok: body.ok ?? true,
50+
status: 200,
51+
statusText: '',
52+
headers: new Headers({ 'content-type': body.contentType ?? 'audio/mpeg' }),
53+
body: null,
54+
text: async () => '',
55+
json: async () => ({}),
56+
arrayBuffer: async () => new ArrayBuffer(8),
57+
}
58+
}
59+
60+
describe('POST /api/tools/stt', () => {
61+
beforeEach(() => {
62+
vi.clearAllMocks()
63+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
64+
success: true,
65+
userId: 'user-1',
66+
authType: 'internal_jwt',
67+
})
68+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
69+
isValid: true,
70+
resolvedIP: PINNED_IP,
71+
originalHostname: 'example.com',
72+
})
73+
mockIsInternalFileUrl.mockReturnValue(false)
74+
75+
vi.stubGlobal(
76+
'fetch',
77+
vi.fn().mockResolvedValue({
78+
ok: true,
79+
json: async () => ({ text: 'hello world', language: 'en', duration: 1.2 }),
80+
})
81+
)
82+
})
83+
84+
it('bounds the audioUrl download and rejects oversized responses cleanly', async () => {
85+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValueOnce(
86+
new PayloadSizeLimitError({
87+
label: 'response body',
88+
maxBytes: 100 * 1024 * 1024,
89+
observedBytes: 200 * 1024 * 1024,
90+
})
91+
)
92+
93+
const response = await POST(createMockRequest('POST', baseBody))
94+
95+
expect(response.status).toBe(413)
96+
const data = (await response.json()) as { error: string }
97+
expect(data.error).toMatch(/exceeds the maximum supported size/i)
98+
99+
const call = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
100+
expect(call[1]).toBe(PINNED_IP)
101+
expect(call[2]).toMatchObject({ maxResponseBytes: 100 * 1024 * 1024 })
102+
})
103+
104+
it('transcribes a normal, well-under-cap audio download successfully', async () => {
105+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
106+
mockSecureFetchResponse({})
107+
)
108+
109+
const response = await POST(createMockRequest('POST', baseBody))
110+
111+
expect(response.status).toBe(200)
112+
const data = (await response.json()) as { transcript: string }
113+
expect(data.transcript).toBe('hello world')
114+
})
115+
})

apps/sim/app/api/tools/stt/route.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
secureFetchWithPinnedIP,
1313
validateUrlWithDNS,
1414
} from '@/lib/core/security/input-validation.server'
15+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1516
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1617
import { getMimeTypeFromExtension, isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
1718
import {
1819
downloadFileFromStorage,
1920
resolveInternalFileUrl,
2021
} from '@/lib/uploads/utils/file-utils.server'
22+
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
2123
import { assertToolFileAccess } from '@/app/api/files/authorization'
2224
import type { TranscriptSegment } from '@/tools/stt/types'
2325

@@ -150,6 +152,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
150152

151153
const response = await secureFetchWithPinnedIP(audioUrl, urlValidation.resolvedIP!, {
152154
method: 'GET',
155+
maxResponseBytes: MAX_FILE_SIZE,
153156
})
154157
if (!response.ok) {
155158
await response.text().catch(() => {})
@@ -297,8 +300,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
297300
return NextResponse.json(response)
298301
} catch (error) {
299302
logger.error(`[${requestId}] STT proxy error:`, error)
300-
const errorMessage = getErrorMessage(error, 'Unknown error')
301-
return NextResponse.json({ error: errorMessage }, { status: 500 })
303+
const errorMessage = isPayloadSizeLimitError(error)
304+
? 'Audio file exceeds the maximum supported size'
305+
: getErrorMessage(error, 'Unknown error')
306+
return NextResponse.json(
307+
{ error: errorMessage },
308+
{ status: isPayloadSizeLimitError(error) ? 413 : 500 }
309+
)
302310
}
303311
})
304312

0 commit comments

Comments
 (0)