Skip to content

Commit 45372ae

Browse files
authored
fix(files): fix vulnerabilities in file uploads/deletes (#1130)
* fix(vulnerability): fix arbitrary file deletion vuln * fix(uploads): fix vuln during upload * cleanup
1 parent 766279b commit 45372ae

File tree

5 files changed

+638
-7
lines changed

5 files changed

+638
-7
lines changed

apps/sim/app/api/files/upload/route.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,190 @@ describe('File Upload API Route', () => {
186186
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type')
187187
})
188188
})
189+
190+
describe('File Upload Security Tests', () => {
191+
beforeEach(() => {
192+
vi.resetModules()
193+
vi.clearAllMocks()
194+
195+
vi.doMock('@/lib/auth', () => ({
196+
getSession: vi.fn().mockResolvedValue({
197+
user: { id: 'test-user-id' },
198+
}),
199+
}))
200+
201+
vi.doMock('@/lib/uploads', () => ({
202+
isUsingCloudStorage: vi.fn().mockReturnValue(false),
203+
uploadFile: vi.fn().mockResolvedValue({
204+
key: 'test-key',
205+
path: '/test/path',
206+
}),
207+
}))
208+
209+
vi.doMock('@/lib/uploads/setup.server', () => ({}))
210+
})
211+
212+
afterEach(() => {
213+
vi.clearAllMocks()
214+
})
215+
216+
describe('File Extension Validation', () => {
217+
it('should accept allowed file types', async () => {
218+
const allowedTypes = [
219+
'pdf',
220+
'doc',
221+
'docx',
222+
'txt',
223+
'md',
224+
'png',
225+
'jpg',
226+
'jpeg',
227+
'gif',
228+
'csv',
229+
'xlsx',
230+
'xls',
231+
]
232+
233+
for (const ext of allowedTypes) {
234+
const formData = new FormData()
235+
const file = new File(['test content'], `test.${ext}`, { type: 'application/octet-stream' })
236+
formData.append('file', file)
237+
238+
const req = new Request('http://localhost/api/files/upload', {
239+
method: 'POST',
240+
body: formData,
241+
})
242+
243+
const { POST } = await import('@/app/api/files/upload/route')
244+
const response = await POST(req as any)
245+
246+
expect(response.status).toBe(200)
247+
}
248+
})
249+
250+
it('should reject HTML files to prevent XSS', async () => {
251+
const formData = new FormData()
252+
const maliciousContent = '<script>alert("XSS")</script>'
253+
const file = new File([maliciousContent], 'malicious.html', { type: 'text/html' })
254+
formData.append('file', file)
255+
256+
const req = new Request('http://localhost/api/files/upload', {
257+
method: 'POST',
258+
body: formData,
259+
})
260+
261+
const { POST } = await import('@/app/api/files/upload/route')
262+
const response = await POST(req as any)
263+
264+
expect(response.status).toBe(400)
265+
const data = await response.json()
266+
expect(data.message).toContain("File type 'html' is not allowed")
267+
})
268+
269+
it('should reject SVG files to prevent XSS', async () => {
270+
const formData = new FormData()
271+
const maliciousSvg = '<svg onload="alert(\'XSS\')" xmlns="http://www.w3.org/2000/svg"></svg>'
272+
const file = new File([maliciousSvg], 'malicious.svg', { type: 'image/svg+xml' })
273+
formData.append('file', file)
274+
275+
const req = new Request('http://localhost/api/files/upload', {
276+
method: 'POST',
277+
body: formData,
278+
})
279+
280+
const { POST } = await import('@/app/api/files/upload/route')
281+
const response = await POST(req as any)
282+
283+
expect(response.status).toBe(400)
284+
const data = await response.json()
285+
expect(data.message).toContain("File type 'svg' is not allowed")
286+
})
287+
288+
it('should reject JavaScript files', async () => {
289+
const formData = new FormData()
290+
const maliciousJs = 'alert("XSS")'
291+
const file = new File([maliciousJs], 'malicious.js', { type: 'application/javascript' })
292+
formData.append('file', file)
293+
294+
const req = new Request('http://localhost/api/files/upload', {
295+
method: 'POST',
296+
body: formData,
297+
})
298+
299+
const { POST } = await import('@/app/api/files/upload/route')
300+
const response = await POST(req as any)
301+
302+
expect(response.status).toBe(400)
303+
const data = await response.json()
304+
expect(data.message).toContain("File type 'js' is not allowed")
305+
})
306+
307+
it('should reject files without extensions', async () => {
308+
const formData = new FormData()
309+
const file = new File(['test content'], 'noextension', { type: 'application/octet-stream' })
310+
formData.append('file', file)
311+
312+
const req = new Request('http://localhost/api/files/upload', {
313+
method: 'POST',
314+
body: formData,
315+
})
316+
317+
const { POST } = await import('@/app/api/files/upload/route')
318+
const response = await POST(req as any)
319+
320+
expect(response.status).toBe(400)
321+
const data = await response.json()
322+
expect(data.message).toContain("File type 'noextension' is not allowed")
323+
})
324+
325+
it('should handle multiple files with mixed valid/invalid types', async () => {
326+
const formData = new FormData()
327+
328+
// Valid file
329+
const validFile = new File(['valid content'], 'valid.pdf', { type: 'application/pdf' })
330+
formData.append('file', validFile)
331+
332+
// Invalid file (should cause rejection of entire request)
333+
const invalidFile = new File(['<script>alert("XSS")</script>'], 'malicious.html', {
334+
type: 'text/html',
335+
})
336+
formData.append('file', invalidFile)
337+
338+
const req = new Request('http://localhost/api/files/upload', {
339+
method: 'POST',
340+
body: formData,
341+
})
342+
343+
const { POST } = await import('@/app/api/files/upload/route')
344+
const response = await POST(req as any)
345+
346+
expect(response.status).toBe(400)
347+
const data = await response.json()
348+
expect(data.message).toContain("File type 'html' is not allowed")
349+
})
350+
})
351+
352+
describe('Authentication Requirements', () => {
353+
it('should reject uploads without authentication', async () => {
354+
vi.doMock('@/lib/auth', () => ({
355+
getSession: vi.fn().mockResolvedValue(null),
356+
}))
357+
358+
const formData = new FormData()
359+
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
360+
formData.append('file', file)
361+
362+
const req = new Request('http://localhost/api/files/upload', {
363+
method: 'POST',
364+
body: formData,
365+
})
366+
367+
const { POST } = await import('@/app/api/files/upload/route')
368+
const response = await POST(req as any)
369+
370+
expect(response.status).toBe(401)
371+
const data = await response.json()
372+
expect(data.error).toBe('Unauthorized')
373+
})
374+
})
375+
})

apps/sim/app/api/files/upload/route.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,34 @@ import {
99
InvalidRequestError,
1010
} from '@/app/api/files/utils'
1111

12+
// Allowlist of permitted file extensions for security
13+
const ALLOWED_EXTENSIONS = new Set([
14+
// Documents
15+
'pdf',
16+
'doc',
17+
'docx',
18+
'txt',
19+
'md',
20+
// Images (safe formats)
21+
'png',
22+
'jpg',
23+
'jpeg',
24+
'gif',
25+
// Data files
26+
'csv',
27+
'xlsx',
28+
'xls',
29+
])
30+
31+
/**
32+
* Validates file extension against allowlist
33+
*/
34+
function validateFileExtension(filename: string): boolean {
35+
const extension = filename.split('.').pop()?.toLowerCase()
36+
if (!extension) return false
37+
return ALLOWED_EXTENSIONS.has(extension)
38+
}
39+
1240
export const dynamic = 'force-dynamic'
1341

1442
const logger = createLogger('FilesUploadAPI')
@@ -49,6 +77,14 @@ export async function POST(request: NextRequest) {
4977
// Process each file
5078
for (const file of files) {
5179
const originalName = file.name
80+
81+
if (!validateFileExtension(originalName)) {
82+
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
83+
throw new InvalidRequestError(
84+
`File type '${extension}' is not allowed. Allowed types: ${Array.from(ALLOWED_EXTENSIONS).join(', ')}`
85+
)
86+
}
87+
5288
const bytes = await file.arrayBuffer()
5389
const buffer = Buffer.from(bytes)
5490

0 commit comments

Comments
 (0)