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
502 lines
19 KiB
TypeScript
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()
|
|
}
|
|
}
|
|
)
|
|
})
|
|
})
|