Skip to content

Commit 34ff739

Browse files
committed
fix(security): harden glob matching, archive parsing, redaction and media handling
Glob matching (copilot VFS): - match globs with RE2 instead of micromatch's backtracking engine, and translate picomatch's output rather than trusting it: reject escape passthrough (\A, \z, \p{L}) that RE2 reads as anchors and Unicode classes, and class ranges straddling the private-use segment markers Archive parsing: - scan the whole buffer for EOCD records, matching JSZip and SheetJS rather than the spec's trailing window - treat a resolvable-but-empty central directory as unverifiable - bound central-directory scanning with a shared record budget - keep stray EOCD byte sequences in non-ZIP documents a no-op so the OLE2 and plaintext fallbacks still run Redaction: - fix quadratic acronym-boundary backtracking (73s to 1ms at 400k chars) - catch plural and value-suffixed secret keys, and one-shot locator URLs Other: - guard YAML alias expansion in the JSON/YAML chunker - pin fal.ai queue polling to its origin and cap the response body - escape ffmpeg drawtext values through a file instead of the filtergraph - resolve redirects against the registrable domain and drop credential headers across sites - let the file preview error boundary retry instead of latching
1 parent dc94879 commit 34ff739

30 files changed

Lines changed: 4845 additions & 309 deletions

apps/sim/app/api/tools/onedrive/upload/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
4+
/** Pinned to the SheetJS CDN, not npm — see the note in `lib/file-parsers/xlsx-parser.ts`. */
45
import * as XLSX from 'xlsx'
56
import { onedriveUploadContract } from '@/lib/api/contracts/tools/microsoft'
67
import { parseRequest } from '@/lib/api/server'
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
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 { MAX_FAL_QUEUE_JSON_BYTES } from '@/lib/media/falai'
12+
13+
const { mockUploadFile } = vi.hoisted(() => ({
14+
mockUploadFile: vi.fn(),
15+
}))
16+
17+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
18+
vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 30_000 }))
19+
vi.mock('@sim/utils/helpers', () => ({ sleep: () => Promise.resolve() }))
20+
vi.mock('@/app/api/files/authorization', () => ({
21+
assertToolFileAccess: vi.fn().mockResolvedValue(null),
22+
}))
23+
vi.mock('@/lib/uploads', () => ({
24+
StorageService: { uploadFile: mockUploadFile },
25+
}))
26+
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))
27+
28+
import { POST } from '@/app/api/tools/video/route'
29+
30+
const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns
31+
32+
function jsonResponse(payload: unknown) {
33+
const text = JSON.stringify(payload)
34+
return {
35+
ok: true,
36+
status: 200,
37+
headers: { get: () => null },
38+
body: null,
39+
text: async () => text,
40+
arrayBuffer: async () => new ArrayBuffer(0),
41+
}
42+
}
43+
44+
function videoResponse() {
45+
return {
46+
ok: true,
47+
status: 200,
48+
headers: {
49+
get: (name: string) => {
50+
if (name === 'content-type') return 'video/mp4'
51+
return name === 'content-length' ? '8' : null
52+
},
53+
},
54+
body: null,
55+
text: async () => '',
56+
arrayBuffer: async () => new ArrayBuffer(8),
57+
}
58+
}
59+
60+
function errorResponse(status: number) {
61+
return {
62+
ok: false,
63+
status,
64+
headers: { get: () => null },
65+
body: null,
66+
text: async () => 'denied',
67+
arrayBuffer: async () => new ArrayBuffer(0),
68+
}
69+
}
70+
71+
const baseBody = {
72+
provider: 'falai',
73+
apiKey: 'fal-key',
74+
model: 'kling-v3-pro',
75+
prompt: 'a cat riding a bike',
76+
}
77+
78+
describe('POST /api/tools/video (Fal.ai queue)', () => {
79+
const fetchMock = vi.fn()
80+
81+
beforeEach(() => {
82+
vi.clearAllMocks()
83+
vi.stubGlobal('fetch', fetchMock)
84+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
85+
success: true,
86+
userId: 'user-1',
87+
authType: 'internal_jwt',
88+
})
89+
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '8.8.8.8' })
90+
mockUploadFile.mockResolvedValue({ path: '/api/files/serve/video.mp4' })
91+
})
92+
93+
it('normalizes the echoed /response URL and bounds queue reads with the shared Fal cap', async () => {
94+
fetchMock.mockResolvedValue(
95+
new Response(
96+
JSON.stringify({
97+
request_id: 'req-1',
98+
status_url: 'https://queue.fal.run/fal-ai/kling-video/requests/req-1/status',
99+
response_url: 'https://queue.fal.run/fal-ai/kling-video/requests/req-1/response',
100+
}),
101+
{ status: 200 }
102+
)
103+
)
104+
mockSecureFetchWithPinnedIP
105+
.mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
106+
.mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
107+
.mockResolvedValueOnce(videoResponse())
108+
109+
const response = await POST(createMockRequest('POST', baseBody))
110+
expect(response.status).toBe(200)
111+
112+
const [statusCall, resultCall, downloadCall] = mockSecureFetchWithPinnedIP.mock.calls
113+
expect(statusCall[0]).toBe('https://queue.fal.run/fal-ai/kling-video/requests/req-1/status')
114+
// `/response` is not a GET route on queue.fal.run — it must be stripped.
115+
expect(resultCall[0]).toBe('https://queue.fal.run/fal-ai/kling-video/requests/req-1')
116+
expect(downloadCall[0]).toBe('https://cdn.fal.media/a.mp4')
117+
118+
expect(statusCall[2].maxResponseBytes).toBe(MAX_FAL_QUEUE_JSON_BYTES)
119+
expect(resultCall[2].maxResponseBytes).toBe(MAX_FAL_QUEUE_JSON_BYTES)
120+
})
121+
122+
it('falls back to the constructed multi-segment queue URL when the candidate is off-origin', async () => {
123+
fetchMock.mockResolvedValue(
124+
new Response(
125+
JSON.stringify({
126+
request_id: 'req-2',
127+
status_url: 'https://evil.example.net/steal',
128+
response_url: 'https://evil.example.net/steal',
129+
}),
130+
{ status: 200 }
131+
)
132+
)
133+
mockSecureFetchWithPinnedIP
134+
.mockResolvedValueOnce(jsonResponse({ status: 'COMPLETED' }))
135+
.mockResolvedValueOnce(jsonResponse({ video: { url: 'https://cdn.fal.media/a.mp4' } }))
136+
.mockResolvedValueOnce(videoResponse())
137+
138+
const response = await POST(createMockRequest('POST', baseBody))
139+
expect(response.status).toBe(200)
140+
141+
// `fal-ai/kling-video/v3/pro/text-to-video` polls under the app id only.
142+
expect(mockSecureFetchWithPinnedIP.mock.calls.slice(0, 2).map(([url]) => url)).toEqual([
143+
'https://queue.fal.run/fal-ai/kling-video/requests/req-2/status',
144+
'https://queue.fal.run/fal-ai/kling-video/requests/req-2',
145+
])
146+
})
147+
})
148+
149+
/**
150+
* Runway, Veo, Luma and MiniMax all download the finished asset through
151+
* `downloadVideoFromUrl` (the SSRF-guarded client), each with its own label and
152+
* error prefix. These cover that plumbing per provider.
153+
*/
154+
describe('POST /api/tools/video (provider download paths)', () => {
155+
const fetchMock = vi.fn()
156+
157+
beforeEach(() => {
158+
vi.clearAllMocks()
159+
vi.stubGlobal('fetch', fetchMock)
160+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
161+
success: true,
162+
userId: 'user-1',
163+
authType: 'internal_jwt',
164+
})
165+
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '8.8.8.8' })
166+
mockUploadFile.mockResolvedValue({ path: '/api/files/serve/video.mp4' })
167+
})
168+
169+
function apiResponse(payload: unknown) {
170+
return new Response(JSON.stringify(payload), { status: 200 })
171+
}
172+
173+
function veoFetches(uri: string) {
174+
fetchMock.mockResolvedValueOnce(apiResponse({ name: 'operations/op-1' })).mockResolvedValueOnce(
175+
apiResponse({
176+
done: true,
177+
response: { generateVideoResponse: { generatedSamples: [{ video: { uri } }] } },
178+
})
179+
)
180+
}
181+
182+
it('downloads the Runway asset through the guarded client', async () => {
183+
fetchMock
184+
.mockResolvedValueOnce(apiResponse({ id: 'task-1' }))
185+
.mockResolvedValueOnce(
186+
apiResponse({ status: 'SUCCEEDED', output: ['https://cdn.runwayml.test/a.mp4'] })
187+
)
188+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
189+
190+
const response = await POST(
191+
createMockRequest('POST', {
192+
provider: 'runway',
193+
apiKey: 'runway-key',
194+
model: 'gen-4',
195+
prompt: 'a cat riding a bike',
196+
})
197+
)
198+
199+
expect(response.status).toBe(200)
200+
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)
201+
expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe('https://cdn.runwayml.test/a.mp4')
202+
expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toBeUndefined()
203+
})
204+
205+
it('surfaces the default download error prefix when the Runway asset fetch fails', async () => {
206+
fetchMock
207+
.mockResolvedValueOnce(apiResponse({ id: 'task-1' }))
208+
.mockResolvedValueOnce(
209+
apiResponse({ status: 'SUCCEEDED', output: ['https://cdn.runwayml.test/a.mp4'] })
210+
)
211+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(errorResponse(401))
212+
213+
const response = await POST(
214+
createMockRequest('POST', {
215+
provider: 'runway',
216+
apiKey: 'runway-key',
217+
model: 'gen-4',
218+
prompt: 'a cat riding a bike',
219+
})
220+
)
221+
222+
expect(response.status).toBe(500)
223+
expect((await response.json()).error).toBe('Failed to download video: 401')
224+
})
225+
226+
it('downloads the Luma asset through the guarded client', async () => {
227+
fetchMock
228+
.mockResolvedValueOnce(apiResponse({ id: 'gen-1' }))
229+
.mockResolvedValueOnce(
230+
apiResponse({ state: 'completed', assets: { video: 'https://cdn.lumalabs.test/a.mp4' } })
231+
)
232+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
233+
234+
const response = await POST(
235+
createMockRequest('POST', {
236+
provider: 'luma',
237+
apiKey: 'luma-key',
238+
model: 'ray-2',
239+
prompt: 'a cat riding a bike',
240+
})
241+
)
242+
243+
expect(response.status).toBe(200)
244+
expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe('https://cdn.lumalabs.test/a.mp4')
245+
expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toBeUndefined()
246+
})
247+
248+
it('keeps the MiniMax-specific download error prefix', async () => {
249+
fetchMock
250+
.mockResolvedValueOnce(apiResponse({ base_resp: { status_code: 0 }, task_id: 'task-1' }))
251+
.mockResolvedValueOnce(
252+
apiResponse({ base_resp: { status_code: 0 }, status: 'Success', file_id: 'file-1' })
253+
)
254+
.mockResolvedValueOnce(
255+
apiResponse({ file: { download_url: 'https://cdn.minimax.test/a.mp4' } })
256+
)
257+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(errorResponse(401))
258+
259+
const response = await POST(
260+
createMockRequest('POST', {
261+
provider: 'minimax',
262+
apiKey: 'minimax-key',
263+
model: 'hailuo-2.3',
264+
prompt: 'a cat riding a bike',
265+
})
266+
)
267+
268+
expect(response.status).toBe(500)
269+
expect((await response.json()).error).toBe('Failed to download video from URL: 401')
270+
})
271+
272+
it('downloads the MiniMax asset through the guarded client', async () => {
273+
fetchMock
274+
.mockResolvedValueOnce(apiResponse({ base_resp: { status_code: 0 }, task_id: 'task-1' }))
275+
.mockResolvedValueOnce(
276+
apiResponse({ base_resp: { status_code: 0 }, status: 'Success', file_id: 'file-1' })
277+
)
278+
.mockResolvedValueOnce(
279+
apiResponse({ file: { download_url: 'https://cdn.minimax.test/a.mp4' } })
280+
)
281+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
282+
283+
const response = await POST(
284+
createMockRequest('POST', {
285+
provider: 'minimax',
286+
apiKey: 'minimax-key',
287+
model: 'hailuo-2.3',
288+
prompt: 'a cat riding a bike',
289+
})
290+
)
291+
292+
expect(response.status).toBe(200)
293+
expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe('https://cdn.minimax.test/a.mp4')
294+
})
295+
296+
it('attaches the Veo API key only for a genuine https Google API host', async () => {
297+
veoFetches('https://generativelanguage.googleapis.com/v1beta/files/a:download')
298+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
299+
300+
const response = await POST(
301+
createMockRequest('POST', {
302+
provider: 'veo',
303+
apiKey: 'veo-key',
304+
model: 'veo-3',
305+
prompt: 'a cat riding a bike',
306+
})
307+
)
308+
309+
expect(response.status).toBe(200)
310+
expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toEqual({
311+
'x-goog-api-key': 'veo-key',
312+
})
313+
})
314+
315+
it.each([
316+
['a suffix-spoofed host', 'https://evil.googleapis.com.attacker.test/a.mp4'],
317+
['a prefix-spoofed host', 'https://xgoogleapis.com/a.mp4'],
318+
['plaintext http', 'http://generativelanguage.googleapis.com/a.mp4'],
319+
])('withholds the Veo API key for %s', async (_label, uri) => {
320+
veoFetches(uri)
321+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(videoResponse())
322+
323+
const response = await POST(
324+
createMockRequest('POST', {
325+
provider: 'veo',
326+
apiKey: 'veo-key',
327+
model: 'veo-3',
328+
prompt: 'a cat riding a bike',
329+
})
330+
)
331+
332+
expect(response.status).toBe(200)
333+
expect(mockSecureFetchWithPinnedIP.mock.calls[0][0]).toBe(uri)
334+
expect(mockSecureFetchWithPinnedIP.mock.calls[0][2].headers).toBeUndefined()
335+
})
336+
})

0 commit comments

Comments
 (0)