mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
* checkpoint workday block * add icon svg * fix workday to use soap api * fix SOAP API * address comments * fix * more type fixes * address more comments * fix files * fix file editor useEffect * fix build issue * fix typing * fix test
421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { createFileResponse, extractFilename, findLocalFile } from '@/app/api/files/utils'
|
|
|
|
describe('extractFilename', () => {
|
|
describe('legitimate file paths', () => {
|
|
it('should extract filename from standard serve path', () => {
|
|
expect(extractFilename('/api/files/serve/test-file.txt')).toBe('test-file.txt')
|
|
})
|
|
|
|
it('should extract filename from serve path with special characters', () => {
|
|
expect(extractFilename('/api/files/serve/document-with-dashes_and_underscores.pdf')).toBe(
|
|
'document-with-dashes_and_underscores.pdf'
|
|
)
|
|
})
|
|
|
|
it('should handle simple filename without serve path', () => {
|
|
expect(extractFilename('simple-file.txt')).toBe('simple-file.txt')
|
|
})
|
|
|
|
it('should extract last segment from nested path', () => {
|
|
expect(extractFilename('nested/path/file.txt')).toBe('file.txt')
|
|
})
|
|
})
|
|
|
|
describe('cloud storage paths', () => {
|
|
it('should preserve S3 path structure', () => {
|
|
expect(extractFilename('/api/files/serve/s3/1234567890-test-file.txt')).toBe(
|
|
's3/1234567890-test-file.txt'
|
|
)
|
|
})
|
|
|
|
it('should preserve S3 path with nested folders', () => {
|
|
expect(extractFilename('/api/files/serve/s3/folder/subfolder/document.pdf')).toBe(
|
|
's3/folder/subfolder/document.pdf'
|
|
)
|
|
})
|
|
|
|
it('should preserve Azure Blob path structure', () => {
|
|
expect(extractFilename('/api/files/serve/blob/1234567890-test-document.pdf')).toBe(
|
|
'blob/1234567890-test-document.pdf'
|
|
)
|
|
})
|
|
|
|
it('should preserve Blob path with nested folders', () => {
|
|
expect(extractFilename('/api/files/serve/blob/uploads/user-files/report.xlsx')).toBe(
|
|
'blob/uploads/user-files/report.xlsx'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('security - path traversal prevention', () => {
|
|
it('should sanitize basic path traversal attempt', () => {
|
|
expect(extractFilename('/api/files/serve/../config.txt')).toBe('config.txt')
|
|
})
|
|
|
|
it('should sanitize deep path traversal attempt', () => {
|
|
expect(extractFilename('/api/files/serve/../../../../../etc/passwd')).toBe('etcpasswd')
|
|
})
|
|
|
|
it('should sanitize multiple path traversal patterns', () => {
|
|
expect(extractFilename('/api/files/serve/../../secret.txt')).toBe('secret.txt')
|
|
})
|
|
|
|
it('should sanitize path traversal with forward slashes', () => {
|
|
expect(extractFilename('/api/files/serve/../../../system/file')).toBe('systemfile')
|
|
})
|
|
|
|
it('should sanitize mixed path traversal patterns', () => {
|
|
expect(extractFilename('/api/files/serve/../folder/../file.txt')).toBe('folderfile.txt')
|
|
})
|
|
|
|
it('should remove directory separators from local filenames', () => {
|
|
expect(extractFilename('/api/files/serve/folder/with/separators.txt')).toBe(
|
|
'folderwithseparators.txt'
|
|
)
|
|
})
|
|
|
|
it('should handle backslash path separators (Windows style)', () => {
|
|
expect(extractFilename('/api/files/serve/folder\\file.txt')).toBe('folderfile.txt')
|
|
})
|
|
})
|
|
|
|
describe('cloud storage path traversal prevention', () => {
|
|
it('should sanitize S3 path traversal attempts while preserving structure', () => {
|
|
expect(extractFilename('/api/files/serve/s3/../config')).toBe('s3/config')
|
|
})
|
|
|
|
it('should sanitize S3 path with nested traversal attempts', () => {
|
|
expect(extractFilename('/api/files/serve/s3/folder/../sensitive/../file.txt')).toBe(
|
|
's3/folder/sensitive/file.txt'
|
|
)
|
|
})
|
|
|
|
it('should sanitize Blob path traversal attempts while preserving structure', () => {
|
|
expect(extractFilename('/api/files/serve/blob/../system.txt')).toBe('blob/system.txt')
|
|
})
|
|
|
|
it('should remove leading dots from cloud path segments', () => {
|
|
expect(extractFilename('/api/files/serve/s3/.hidden/../file.txt')).toBe('s3/hidden/file.txt')
|
|
})
|
|
})
|
|
|
|
describe('edge cases and error handling', () => {
|
|
it('should handle filename with dots (but not traversal)', () => {
|
|
expect(extractFilename('/api/files/serve/file.with.dots.txt')).toBe('file.with.dots.txt')
|
|
})
|
|
|
|
it('should handle filename with multiple extensions', () => {
|
|
expect(extractFilename('/api/files/serve/archive.tar.gz')).toBe('archive.tar.gz')
|
|
})
|
|
|
|
it('should throw error for empty filename after sanitization', () => {
|
|
expect(() => extractFilename('/api/files/serve/')).toThrow(
|
|
'Invalid or empty filename after sanitization'
|
|
)
|
|
})
|
|
|
|
it('should throw error for filename that becomes empty after path traversal removal', () => {
|
|
expect(() => extractFilename('/api/files/serve/../..')).toThrow(
|
|
'Invalid or empty filename after sanitization'
|
|
)
|
|
})
|
|
|
|
it('should handle single character filenames', () => {
|
|
expect(extractFilename('/api/files/serve/a')).toBe('a')
|
|
})
|
|
|
|
it('should handle numeric filenames', () => {
|
|
expect(extractFilename('/api/files/serve/123')).toBe('123')
|
|
})
|
|
})
|
|
|
|
describe('backward compatibility', () => {
|
|
it('should match old behavior for legitimate local files', () => {
|
|
// These test cases verify that our security fix maintains exact backward compatibility
|
|
// for all legitimate use cases found in the existing codebase
|
|
expect(extractFilename('/api/files/serve/test-file.txt')).toBe('test-file.txt')
|
|
expect(extractFilename('/api/files/serve/nonexistent.txt')).toBe('nonexistent.txt')
|
|
})
|
|
|
|
it('should match old behavior for legitimate cloud files', () => {
|
|
// These test cases are from the actual delete route tests
|
|
expect(extractFilename('/api/files/serve/s3/1234567890-test-file.txt')).toBe(
|
|
's3/1234567890-test-file.txt'
|
|
)
|
|
expect(extractFilename('/api/files/serve/blob/1234567890-test-document.pdf')).toBe(
|
|
'blob/1234567890-test-document.pdf'
|
|
)
|
|
})
|
|
|
|
it('should match old behavior for simple paths', () => {
|
|
// These match the mock implementations in serve route tests
|
|
expect(extractFilename('simple-file.txt')).toBe('simple-file.txt')
|
|
expect(extractFilename('nested/path/file.txt')).toBe('file.txt')
|
|
})
|
|
})
|
|
|
|
describe('File Serving Security Tests', () => {
|
|
describe('createFileResponse security headers', () => {
|
|
it('should serve safe images inline with proper headers', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('fake-image-data'),
|
|
contentType: 'image/png',
|
|
filename: 'safe-image.png',
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('image/png')
|
|
expect(response.headers.get('Content-Disposition')).toBe(
|
|
'inline; filename="safe-image.png"'
|
|
)
|
|
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
|
expect(response.headers.get('Content-Security-Policy')).toBeNull()
|
|
})
|
|
|
|
it('should serve PDFs inline safely', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('fake-pdf-data'),
|
|
contentType: 'application/pdf',
|
|
filename: 'document.pdf',
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('application/pdf')
|
|
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="document.pdf"')
|
|
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
|
})
|
|
|
|
it('should force attachment for HTML files to prevent XSS', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('<script>alert("XSS")</script>'),
|
|
contentType: 'text/html',
|
|
filename: 'malicious.html',
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
|
|
expect(response.headers.get('Content-Disposition')).toBe(
|
|
'attachment; filename="malicious.html"'
|
|
)
|
|
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
|
})
|
|
|
|
it('should serve SVG files inline with CSP sandbox protection', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from(
|
|
'<svg onload="alert(\'XSS\')" xmlns="http://www.w3.org/2000/svg"></svg>'
|
|
),
|
|
contentType: 'image/svg+xml',
|
|
filename: 'image.svg',
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('image/svg+xml')
|
|
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="image.svg"')
|
|
expect(response.headers.get('Content-Security-Policy')).toBe(
|
|
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
|
|
)
|
|
})
|
|
|
|
it('should not apply CSP sandbox to non-SVG files', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('hello'),
|
|
contentType: 'text/plain',
|
|
filename: 'readme.txt',
|
|
})
|
|
|
|
expect(response.headers.get('Content-Security-Policy')).toBeNull()
|
|
})
|
|
|
|
it('should force attachment for JavaScript files', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('alert("XSS")'),
|
|
contentType: 'application/javascript',
|
|
filename: 'malicious.js',
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
|
|
expect(response.headers.get('Content-Disposition')).toBe(
|
|
'attachment; filename="malicious.js"'
|
|
)
|
|
})
|
|
|
|
it('should force attachment for CSS files', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('body { background: url(javascript:alert("XSS")) }'),
|
|
contentType: 'text/css',
|
|
filename: 'malicious.css',
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
|
|
expect(response.headers.get('Content-Disposition')).toBe(
|
|
'attachment; filename="malicious.css"'
|
|
)
|
|
})
|
|
|
|
it('should force attachment for XML files', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('<?xml version="1.0"?><root><script>alert("XSS")</script></root>'),
|
|
contentType: 'application/xml',
|
|
filename: 'malicious.xml',
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
|
|
expect(response.headers.get('Content-Disposition')).toBe(
|
|
'attachment; filename="malicious.xml"'
|
|
)
|
|
})
|
|
|
|
it('should serve text files safely', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('Safe text content'),
|
|
contentType: 'text/plain',
|
|
filename: 'document.txt',
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('text/plain')
|
|
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="document.txt"')
|
|
})
|
|
|
|
it('should force attachment for unknown/unsafe content types', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('unknown content'),
|
|
contentType: 'application/unknown',
|
|
filename: 'unknown.bin',
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('application/unknown')
|
|
expect(response.headers.get('Content-Disposition')).toBe(
|
|
'attachment; filename="unknown.bin"'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('Content Security Policy', () => {
|
|
it('should include CSP header only for SVG responses', () => {
|
|
const svgResponse = createFileResponse({
|
|
buffer: Buffer.from('<svg></svg>'),
|
|
contentType: 'image/svg+xml',
|
|
filename: 'icon.svg',
|
|
})
|
|
expect(svgResponse.headers.get('Content-Security-Policy')).toBe(
|
|
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
|
|
)
|
|
|
|
const txtResponse = createFileResponse({
|
|
buffer: Buffer.from('test'),
|
|
contentType: 'text/plain',
|
|
filename: 'test.txt',
|
|
})
|
|
expect(txtResponse.headers.get('Content-Security-Policy')).toBeNull()
|
|
})
|
|
|
|
it('should include X-Content-Type-Options header', () => {
|
|
const response = createFileResponse({
|
|
buffer: Buffer.from('test'),
|
|
contentType: 'text/plain',
|
|
filename: 'test.txt',
|
|
})
|
|
|
|
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('findLocalFile - Path Traversal Security Tests', () => {
|
|
describe('path traversal attack prevention', () => {
|
|
it.concurrent('should reject classic path traversal attacks', async () => {
|
|
const maliciousInputs = [
|
|
'../../../etc/passwd',
|
|
'..\\..\\..\\windows\\system32\\config\\sam',
|
|
'../../../../etc/shadow',
|
|
'../config.json',
|
|
'..\\config.ini',
|
|
]
|
|
|
|
for (const input of maliciousInputs) {
|
|
const result = await findLocalFile(input)
|
|
expect(result).toBeNull()
|
|
}
|
|
})
|
|
|
|
it.concurrent('should reject encoded path traversal attempts', async () => {
|
|
const encodedInputs = [
|
|
'%2e%2e%2f%2e%2e%2f%65%74%63%2f%70%61%73%73%77%64', // ../../../etc/passwd
|
|
'..%2f..%2fetc%2fpasswd',
|
|
'..%5c..%5cconfig.ini',
|
|
]
|
|
|
|
for (const input of encodedInputs) {
|
|
const result = await findLocalFile(input)
|
|
expect(result).toBeNull()
|
|
}
|
|
})
|
|
|
|
it.concurrent('should reject mixed path separators', async () => {
|
|
const mixedInputs = ['../..\\config.txt', '..\\../secret.ini', '/..\\..\\system32']
|
|
|
|
for (const input of mixedInputs) {
|
|
const result = await findLocalFile(input)
|
|
expect(result).toBeNull()
|
|
}
|
|
})
|
|
|
|
it.concurrent('should reject filenames with dangerous characters', async () => {
|
|
const dangerousInputs = [
|
|
'file:with:colons.txt',
|
|
'file|with|pipes.txt',
|
|
'file?with?questions.txt',
|
|
'file*with*asterisks.txt',
|
|
]
|
|
|
|
for (const input of dangerousInputs) {
|
|
const result = await findLocalFile(input)
|
|
expect(result).toBeNull()
|
|
}
|
|
})
|
|
|
|
it.concurrent('should reject null and empty inputs', async () => {
|
|
expect(await findLocalFile('')).toBeNull()
|
|
expect(await findLocalFile(' ')).toBeNull()
|
|
expect(await findLocalFile('\t\n')).toBeNull()
|
|
})
|
|
|
|
it.concurrent('should reject filenames that become empty after sanitization', async () => {
|
|
const emptyAfterSanitization = ['../..', '..\\..\\', '////', '....', '..']
|
|
|
|
for (const input of emptyAfterSanitization) {
|
|
const result = await findLocalFile(input)
|
|
expect(result).toBeNull()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('security validation passes for legitimate files', () => {
|
|
it.concurrent(
|
|
'should accept properly formatted filenames without throwing errors',
|
|
async () => {
|
|
const legitimateInputs = [
|
|
'document.pdf',
|
|
'image.png',
|
|
'data.csv',
|
|
'report-2024.doc',
|
|
'file_with_underscores.txt',
|
|
'file-with-dashes.json',
|
|
]
|
|
|
|
for (const input of legitimateInputs) {
|
|
await expect(findLocalFile(input)).resolves.toBeDefined()
|
|
}
|
|
}
|
|
)
|
|
})
|
|
})
|