import {Request} from '@loopback/rest'; import * as path from 'path'; import {DEFAULT_FILE_UPLOAD_CONFIG} from '../constants/security-defaults'; import {FileUploadConfig} from '../types'; import {SecurityError} from '../utils/security-error'; import {SecurityLogger} from '../utils/security-logger'; /** * File upload handler that validates file uploads for security */ export class FileUploadHandler { private readonly allowedMimeTypes: Set; private readonly allowedExtensions: Set; private readonly dangerousExtensions: Set; constructor( private config?: FileUploadConfig, private logger?: SecurityLogger ) { // Set default configuration this.config = { ...DEFAULT_FILE_UPLOAD_CONFIG, ...config }; this.allowedMimeTypes = new Set(this.config.allowedMimeTypes?.map(type => type.toLowerCase()) ?? []); this.allowedExtensions = new Set(this.config.allowedExtensions?.map(ext => ext.toLowerCase()) ?? []); // Common dangerous file extensions this.dangerousExtensions = new Set([ '.exe', '.bat', '.cmd', '.com', '.pif', '.scr', '.vbs', '.js', '.jar', '.app', '.deb', '.pkg', '.dmg', '.rpm', '.run', '.bin', '.sh', '.ps1', '.php', '.asp', '.aspx', '.jsp', '.py', '.rb', '.pl' ]); this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'FileUploadHandler', maxFileSize: this.config.maxFileSize, maxFiles: this.config.maxFiles, allowedTypes: this.allowedMimeTypes.size, allowedExtensions: this.allowedExtensions.size } }); } /** * Validate file uploads in the request */ async validateFileUploads(req: Request): Promise { try { // Check if request contains file uploads const files = this.extractFiles(req); if (!files || files.length === 0) { return; // No files to validate } // Check maximum number of files if (files.length > (this.config?.maxFiles ?? 10)) { this.logger?.logSecurityViolation(req, 'Too many files uploaded', { component: 'FileUploadHandler', filesUploaded: files.length, maxAllowed: this.config?.maxFiles }); throw SecurityError.fileUploadViolation(`Too many files. Maximum allowed: ${this.config?.maxFiles}`); } // Validate each file for (const file of files) { await this.validateSingleFile(file, req); } this.logger?.logSecurityEvent(req, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'FileUploadHandler', filesValidated: files.length, totalSize: files.reduce((sum, f) => sum + (f.size ?? 0), 0) } }); } catch (error) { if (error instanceof SecurityError) { throw error; } this.logger?.logSecurityViolation(req, error instanceof Error ? error : String(error), { component: 'FileUploadHandler', method: 'validateFileUploads' }); throw SecurityError.fileUploadViolation('File upload validation failed'); } } /** * Extract files from request (simplified - depends on middleware used) */ private extractFiles(req: Request): FileUploadData[] { // This is a simplified implementation // In practice, you'd integrate with multer or similar middleware const files: FileUploadData[] = []; // Check if files are in req.files (multer format) if (req.body && typeof req.body === 'object') { const requestBody = req.body as Record; if (requestBody.files) { const fileData = requestBody.files; if (Array.isArray(fileData)) { files.push(...fileData as FileUploadData[]); } else if (typeof fileData === 'object' && fileData !== null) { files.push(fileData as FileUploadData); } } } return files; } /** * Validate a single file */ private async validateSingleFile(file: FileUploadData, req: Request): Promise { // Validate file size if (file.size && file.size > (this.config?.maxFileSize ?? 5 * 1024 * 1024)) { this.logger?.logSecurityViolation(req, 'File size exceeds limit', { component: 'FileUploadHandler', fileName: file.name, fileSize: file.size, maxSize: this.config?.maxFileSize }); throw SecurityError.fileUploadViolation(`File too large: ${file.name}. Maximum size: ${this.config?.maxFileSize} bytes`); } // Validate file extension if (file.name) { const extension = path.extname(file.name).toLowerCase(); // Check for dangerous extensions if (this.dangerousExtensions.has(extension) && !this.config?.allowExecutables) { this.logger?.logSecurityViolation(req, 'Dangerous file extension detected', { component: 'FileUploadHandler', fileName: file.name, extension, dangerous: true }); throw SecurityError.fileUploadViolation(`Dangerous file type not allowed: ${extension}`); } // Check if extension is in allowed list if (this.allowedExtensions.size > 0 && !this.allowedExtensions.has(extension)) { this.logger?.logSecurityViolation(req, 'File extension not allowed', { component: 'FileUploadHandler', fileName: file.name, extension, allowedExtensions: Array.from(this.allowedExtensions) }); throw SecurityError.fileUploadViolation(`File extension not allowed: ${extension}`); } } // Validate MIME type if (file.mimetype) { const mimeType = file.mimetype.toLowerCase(); if (this.allowedMimeTypes.size > 0 && !this.allowedMimeTypes.has(mimeType)) { this.logger?.logSecurityViolation(req, 'MIME type not allowed', { component: 'FileUploadHandler', fileName: file.name, mimeType, allowedTypes: Array.from(this.allowedMimeTypes) }); throw SecurityError.fileUploadViolation(`MIME type not allowed: ${mimeType}`); } } // Validate file content if configured if (this.config?.validateContent && file.buffer) { await this.validateFileContent(file, req); } // Scan for malware if configured if (this.config?.scanForMalware && file.buffer) { await this.scanForMalware(file, req); } } /** * Validate file content matches declared type */ private async validateFileContent(file: FileUploadData, req: Request): Promise { if (!file.buffer) { return; } try { // Read file signature (magic bytes) const fileSignature = this.getFileSignature(file.buffer); // Check common file signatures const detectedType = this.detectFileType(fileSignature); if (detectedType && file.mimetype) { const expectedTypes = this.getMimeTypesForSignature(detectedType); if (!expectedTypes.includes(file.mimetype.toLowerCase())) { this.logger?.logSecurityViolation(req, 'File content does not match declared type', { component: 'FileUploadHandler', fileName: file.name, declaredType: file.mimetype, detectedType, signature: fileSignature }); throw SecurityError.fileUploadViolation(`File content mismatch: declared ${file.mimetype}, detected ${detectedType}`); } } // Check for embedded executables or scripts if (this.containsSuspiciousContent(file.buffer)) { this.logger?.logSecurityViolation(req, 'Suspicious content detected in file', { component: 'FileUploadHandler', fileName: file.name, suspiciousContent: true }); throw SecurityError.fileUploadViolation('Suspicious content detected in file'); } } catch (error) { if (error instanceof SecurityError) { throw error; } this.logger?.logSecurityViolation(req, 'File content validation failed', { component: 'FileUploadHandler', fileName: file.name, error: error instanceof Error ? error.message : String(error) }); throw SecurityError.fileUploadViolation('File content validation failed'); } } /** * Get file signature from buffer */ private getFileSignature(buffer: Buffer): string { // Get first 8 bytes as hex string return buffer.subarray(0, 8).toString('hex').toUpperCase(); } /** * Detect file type from signature */ private detectFileType(signature: string): string | null { const signatures: Record = { 'FFD8FF': 'image/jpeg', '89504E47': 'image/png', '47494638': 'image/gif', '25504446': 'application/pdf', '504B0304': 'application/zip', 'D0CF11E0': 'application/msoffice', '4D5A': 'application/executable' }; for (const [sig, type] of Object.entries(signatures)) { if (signature.startsWith(sig)) { return type; } } return null; } /** * Get expected MIME types for a detected file signature */ private getMimeTypesForSignature(detectedType: string): string[] { const typeMap: Record = { 'image/jpeg': ['image/jpeg', 'image/jpg'], 'image/png': ['image/png'], 'image/gif': ['image/gif'], 'application/pdf': ['application/pdf'], 'application/zip': ['application/zip', 'application/x-zip-compressed'], 'application/msoffice': ['application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint'], 'application/executable': [] // Never allowed unless explicitly configured }; return typeMap[detectedType] ?? []; } /** * Check for suspicious content in file */ private containsSuspiciousContent(buffer: Buffer): boolean { const content = buffer.toString('utf8', 0, Math.min(buffer.length, 1024)); // Check for common script patterns const suspiciousPatterns = [ /[\s\S]*?<\/script>/i, /javascript:/i, /vbscript:/i, /data:text\/html/i, /eval\s*\(/i, /document\.write/i, /window\.location/i, /%3cscript/i, /<script/i ]; return suspiciousPatterns.some(pattern => pattern.test(content)); } /** * Scan file for malware (placeholder implementation) */ private async scanForMalware(file: FileUploadData, req: Request): Promise { // This is a placeholder. In a real implementation, you would integrate // with antivirus services like ClamAV, VirusTotal API, etc. if (!file.buffer) { return; } try { // Simulate malware scanning await new Promise(resolve => setTimeout(resolve, 100)); // For demo purposes, consider any file with "virus" in name as malware if (file.name?.toLowerCase().includes('virus') || file.name?.toLowerCase().includes('malware')) { this.logger?.logSecurityViolation(req, 'Malware detected in uploaded file', { component: 'FileUploadHandler', fileName: file.name, malwareDetected: true }); throw SecurityError.fileUploadViolation(`Malware detected in file: ${file.name}`); } this.logger?.logSecurityEvent(req, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'FileUploadHandler', fileName: file.name, malwareScanResult: 'clean' } }); } catch (error) { if (error instanceof SecurityError) { throw error; } this.logger?.logSecurityViolation(req, 'Malware scan failed', { component: 'FileUploadHandler', fileName: file.name, error: error instanceof Error ? error.message : String(error) }); throw SecurityError.fileUploadViolation('Malware scan failed'); } } /** * Get upload statistics */ getStats(): { maxFileSize: number; maxFiles: number; allowedMimeTypes: string[]; allowedExtensions: string[]; validateContent: boolean; scanForMalware: boolean; allowExecutables: boolean; } { return { maxFileSize: this.config?.maxFileSize ?? 0, maxFiles: this.config?.maxFiles ?? 0, allowedMimeTypes: Array.from(this.allowedMimeTypes), allowedExtensions: Array.from(this.allowedExtensions), validateContent: this.config?.validateContent ?? false, scanForMalware: this.config?.scanForMalware ?? false, allowExecutables: this.config?.allowExecutables ?? false }; } /** * Add allowed MIME type */ addAllowedMimeType(mimeType: string): void { this.allowedMimeTypes.add(mimeType.toLowerCase()); this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'FileUploadHandler', action: 'MIME_TYPE_ADDED', mimeType: mimeType.toLowerCase() } }); } /** * Remove allowed MIME type */ removeAllowedMimeType(mimeType: string): void { this.allowedMimeTypes.delete(mimeType.toLowerCase()); this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'FileUploadHandler', action: 'MIME_TYPE_REMOVED', mimeType: mimeType.toLowerCase() } }); } /** * Add allowed file extension */ addAllowedExtension(extension: string): void { this.allowedExtensions.add(extension.toLowerCase()); this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'FileUploadHandler', action: 'EXTENSION_ADDED', extension: extension.toLowerCase() } }); } /** * Remove allowed file extension */ removeAllowedExtension(extension: string): void { this.allowedExtensions.delete(extension.toLowerCase()); this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'FileUploadHandler', action: 'EXTENSION_REMOVED', extension: extension.toLowerCase() } }); } /** * Update configuration */ updateConfig(config: Partial): void { this.config = {...this.config, ...config}; // Update internal sets if mime types or extensions changed if (config.allowedMimeTypes) { this.allowedMimeTypes.clear(); config.allowedMimeTypes.forEach(type => this.allowedMimeTypes.add(type.toLowerCase())); } if (config.allowedExtensions) { this.allowedExtensions.clear(); config.allowedExtensions.forEach(ext => this.allowedExtensions.add(ext.toLowerCase())); } this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'FileUploadHandler', action: 'CONFIG_UPDATED', changes: Object.keys(config) } }); } } /** * Interface for file upload data */ interface FileUploadData { name?: string; mimetype?: string; size?: number; buffer?: Buffer; path?: string; }