Skip to content

Commit 172d51e

Browse files
authored
fix(security): fix ssrf vuln and path validation for files route (#1325)
* update infra and remove railway * fix(security): fix ssrf vuln * fix path validation for file serve * Revert "update infra and remove railway" This reverts commit abfa2f8. * lint * ack PR comments
1 parent 065fc5b commit 172d51e

File tree

4 files changed

+288
-70
lines changed

4 files changed

+288
-70
lines changed

apps/sim/app/api/files/utils.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { createFileResponse, extractFilename } from './utils'
2+
import { createFileResponse, extractFilename, findLocalFile } from './utils'
33

44
describe('extractFilename', () => {
55
describe('legitimate file paths', () => {
@@ -325,3 +325,91 @@ describe('extractFilename', () => {
325325
})
326326
})
327327
})
328+
329+
describe('findLocalFile - Path Traversal Security Tests', () => {
330+
describe('path traversal attack prevention', () => {
331+
it.concurrent('should reject classic path traversal attacks', () => {
332+
const maliciousInputs = [
333+
'../../../etc/passwd',
334+
'..\\..\\..\\windows\\system32\\config\\sam',
335+
'../../../../etc/shadow',
336+
'../config.json',
337+
'..\\config.ini',
338+
]
339+
340+
maliciousInputs.forEach((input) => {
341+
const result = findLocalFile(input)
342+
expect(result).toBeNull()
343+
})
344+
})
345+
346+
it.concurrent('should reject encoded path traversal attempts', () => {
347+
const encodedInputs = [
348+
'%2e%2e%2f%2e%2e%2f%65%74%63%2f%70%61%73%73%77%64', // ../../../etc/passwd
349+
'..%2f..%2fetc%2fpasswd',
350+
'..%5c..%5cconfig.ini',
351+
]
352+
353+
encodedInputs.forEach((input) => {
354+
const result = findLocalFile(input)
355+
expect(result).toBeNull()
356+
})
357+
})
358+
359+
it.concurrent('should reject mixed path separators', () => {
360+
const mixedInputs = ['../..\\config.txt', '..\\../secret.ini', '/..\\..\\system32']
361+
362+
mixedInputs.forEach((input) => {
363+
const result = findLocalFile(input)
364+
expect(result).toBeNull()
365+
})
366+
})
367+
368+
it.concurrent('should reject filenames with dangerous characters', () => {
369+
const dangerousInputs = [
370+
'file:with:colons.txt',
371+
'file|with|pipes.txt',
372+
'file?with?questions.txt',
373+
'file*with*asterisks.txt',
374+
]
375+
376+
dangerousInputs.forEach((input) => {
377+
const result = findLocalFile(input)
378+
expect(result).toBeNull()
379+
})
380+
})
381+
382+
it.concurrent('should reject null and empty inputs', () => {
383+
expect(findLocalFile('')).toBeNull()
384+
expect(findLocalFile(' ')).toBeNull()
385+
expect(findLocalFile('\t\n')).toBeNull()
386+
})
387+
388+
it.concurrent('should reject filenames that become empty after sanitization', () => {
389+
const emptyAfterSanitization = ['../..', '..\\..\\', '////', '....', '..']
390+
391+
emptyAfterSanitization.forEach((input) => {
392+
const result = findLocalFile(input)
393+
expect(result).toBeNull()
394+
})
395+
})
396+
})
397+
398+
describe('security validation passes for legitimate files', () => {
399+
it.concurrent('should accept properly formatted filenames without throwing errors', () => {
400+
const legitimateInputs = [
401+
'document.pdf',
402+
'image.png',
403+
'data.csv',
404+
'report-2024.doc',
405+
'file_with_underscores.txt',
406+
'file-with-dashes.json',
407+
]
408+
409+
legitimateInputs.forEach((input) => {
410+
// Should not throw security errors for legitimate filenames
411+
expect(() => findLocalFile(input)).not.toThrow()
412+
})
413+
})
414+
})
415+
})

apps/sim/app/api/files/utils.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { existsSync } from 'fs'
2-
import { join } from 'path'
2+
import { join, resolve, sep } from 'path'
33
import { NextResponse } from 'next/server'
4+
import { createLogger } from '@/lib/logs/console/logger'
45
import { UPLOAD_DIR } from '@/lib/uploads/setup'
56

7+
const logger = createLogger('FilesUtils')
8+
69
/**
710
* Response type definitions
811
*/
@@ -192,18 +195,71 @@ export function extractFilename(path: string): string {
192195
}
193196

194197
/**
195-
* Find a file in possible local storage locations
198+
* Sanitize filename to prevent path traversal attacks
199+
*/
200+
function sanitizeFilename(filename: string): string {
201+
if (!filename || typeof filename !== 'string') {
202+
throw new Error('Invalid filename provided')
203+
}
204+
205+
const sanitized = filename
206+
.replace(/\.\./g, '') // Remove .. sequences
207+
.replace(/[/\\]/g, '') // Remove path separators
208+
.replace(/^\./g, '') // Remove leading dots
209+
.trim()
210+
211+
if (!sanitized || sanitized.length === 0) {
212+
throw new Error('Invalid or empty filename after sanitization')
213+
}
214+
215+
if (
216+
sanitized.includes(':') ||
217+
sanitized.includes('|') ||
218+
sanitized.includes('?') ||
219+
sanitized.includes('*') ||
220+
sanitized.includes('\x00') || // Null bytes
221+
/[\x00-\x1F\x7F]/.test(sanitized) // Control characters
222+
) {
223+
throw new Error('Filename contains invalid characters')
224+
}
225+
226+
return sanitized
227+
}
228+
229+
/**
230+
* Find a file in possible local storage locations with proper path validation
196231
*/
197232
export function findLocalFile(filename: string): string | null {
198-
const possiblePaths = [join(UPLOAD_DIR, filename), join(process.cwd(), 'uploads', filename)]
233+
try {
234+
const sanitizedFilename = sanitizeFilename(filename)
235+
236+
const possiblePaths = [
237+
join(UPLOAD_DIR, sanitizedFilename),
238+
join(process.cwd(), 'uploads', sanitizedFilename),
239+
]
240+
241+
for (const path of possiblePaths) {
242+
const resolvedPath = resolve(path)
243+
const allowedDirs = [resolve(UPLOAD_DIR), resolve(process.cwd(), 'uploads')]
199244

200-
for (const path of possiblePaths) {
201-
if (existsSync(path)) {
202-
return path
245+
const isWithinAllowedDir = allowedDirs.some(
246+
(allowedDir) => resolvedPath.startsWith(allowedDir + sep) || resolvedPath === allowedDir
247+
)
248+
249+
if (!isWithinAllowedDir) {
250+
continue // Skip this path as it's outside allowed directories
251+
}
252+
253+
if (existsSync(resolvedPath)) {
254+
return resolvedPath
255+
}
203256
}
204-
}
205257

206-
return null
258+
return null
259+
} catch (error) {
260+
logger.error('Error in findLocalFile:', error)
261+
return null
262+
}
207263
}
208264

209265
const SAFE_INLINE_TYPES = new Set([

0 commit comments

Comments
 (0)