Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

502 lines
19 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
createFileResponse,
encodeFilenameForHeader,
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('defaults to a PRIVATE cache so access-verified content is never shared-cached', () => {
const response = createFileResponse({
buffer: Buffer.from('fake-image-data'),
contentType: 'image/png',
filename: 'safe-image.png',
})
// No explicit cacheControl → must NOT be `public` (a shared cache/CDN could re-serve authed bytes).
expect(response.headers.get('Cache-Control')).toBe('private, no-cache')
})
it('honors an explicit cacheControl (e.g. public assets opt in)', () => {
const response = createFileResponse({
buffer: Buffer.from('fake-image-data'),
contentType: 'image/png',
filename: 'avatar.png',
cacheControl: 'public, max-age=31536000',
})
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000')
})
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"'
)
})
})
/**
* `originalName` is attacker-controlled — it only rejects path separators — so a
* quote can reach the header and close the quoted parameter early. RFC 6266 tells
* clients to prefer `filename*`, so an injected one decides the name the file
* lands under on disk regardless of what the product UI displayed.
*/
describe('encodeFilenameForHeader parameter injection', () => {
it('neutralizes a quote that would close the quoted filename parameter', () => {
expect(encodeFilenameForHeader(`report.pdf"; filename*=UTF-8''invoice.html`)).toBe(
`filename="report.pdf__ filename*=UTF-8''invoice.html"; filename*=UTF-8''report.pdf%22%3B%20filename%2A%3DUTF-8%27%27invoice.html`
)
})
it('neutralizes the same injection on the non-ascii branch', () => {
expect(encodeFilenameForHeader(`repört.pdf"; filename*=UTF-8''invoice.html`)).toBe(
`filename="rep_rt.pdf__ filename*=UTF-8''invoice.html"; filename*=UTF-8''rep%C3%B6rt.pdf%22%3B%20filename%2A%3DUTF-8%27%27invoice.html`
)
})
it('emits exactly one filename* parameter, holding the real name', () => {
const name = `report.pdf"; filename*=UTF-8''invoice.html`
const header = encodeFilenameForHeader(name)
// Strip the quoted value: text inside it is inert, so only what follows counts.
const parameters = `${header.slice(0, header.indexOf('filename="'))}${header.slice(header.lastIndexOf('"') + 1)}`
expect(parameters.match(/filename\*=/g)).toHaveLength(1)
// The one surviving filename* is the real name, not the injected one.
expect(decodeURIComponent(parameters.split(`filename*=UTF-8''`)[1])).toBe(name)
})
it('percent-encodes an apostrophe so it cannot desync the ext-value delimiter', () => {
const header = encodeFilenameForHeader("it's a café.pdf")
expect(header.split(`filename*=UTF-8''`)[1]).toBe('it%27s%20a%20caf%C3%A9.pdf')
})
it('encodes control characters that would otherwise be an invalid header value', () => {
const header = encodeFilenameForHeader('report\r\nX-Injected: 1.pdf')
expect(header).toBe(
`filename="report__X-Injected: 1.pdf"; filename*=UTF-8''report%0D%0AX-Injected%3A%201.pdf`
)
expect(
() =>
new Response('data', { headers: { 'Content-Disposition': `attachment; ${header}` } })
).not.toThrow()
})
it('leaves an ordinary ascii filename byte-identical', () => {
expect(encodeFilenameForHeader('quarterly-report (final).pdf')).toBe(
'filename="quarterly-report (final).pdf"'
)
})
it('strips the directory prefix before encoding', () => {
expect(encodeFilenameForHeader('workspace/abc/report.pdf')).toBe('filename="report.pdf"')
})
})
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()
}
}
)
})
})