xpeditis2.0/apps/backend/src/application/services/file-validation.service.ts
2025-11-04 07:30:15 +01:00

211 lines
5.5 KiB
TypeScript

/**
* File Validation Service
*
* Validates uploaded files for security
*/
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { fileUploadConfig } from '../../infrastructure/security/security.config';
import * as path from 'path';
export interface FileValidationResult {
valid: boolean;
errors: string[];
}
@Injectable()
export class FileValidationService {
private readonly logger = new Logger(FileValidationService.name);
/**
* Validate uploaded file
*/
async validateFile(file: Express.Multer.File): Promise<FileValidationResult> {
const errors: string[] = [];
// Check if file exists
if (!file) {
errors.push('No file provided');
return { valid: false, errors };
}
// Validate file size
if (file.size > fileUploadConfig.maxFileSize) {
errors.push(
`File size exceeds maximum allowed size of ${fileUploadConfig.maxFileSize / 1024 / 1024}MB`
);
}
// Validate MIME type
if (!fileUploadConfig.allowedMimeTypes.includes(file.mimetype)) {
errors.push(
`File type ${
file.mimetype
} is not allowed. Allowed types: ${fileUploadConfig.allowedMimeTypes.join(', ')}`
);
}
// Validate file extension
const ext = path.extname(file.originalname).toLowerCase();
if (!fileUploadConfig.allowedExtensions.includes(ext)) {
errors.push(
`File extension ${ext} is not allowed. Allowed extensions: ${fileUploadConfig.allowedExtensions.join(
', '
)}`
);
}
// Validate filename (prevent directory traversal)
if (this.containsDirectoryTraversal(file.originalname)) {
errors.push('Invalid filename: directory traversal detected');
}
// Check for executable files disguised with double extensions
if (this.hasDoubleExtension(file.originalname)) {
errors.push('Invalid filename: double extension detected');
}
// Validate file content matches extension (basic check)
if (!this.contentMatchesExtension(file)) {
errors.push('File content does not match extension');
}
const valid = errors.length === 0;
if (!valid) {
this.logger.warn(`File validation failed: ${errors.join(', ')}`);
}
return { valid, errors };
}
/**
* Validate and sanitize filename
*/
sanitizeFilename(filename: string): string {
// Remove path traversal attempts
let sanitized = path.basename(filename);
// Remove special characters except dot, dash, underscore
sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_');
// Limit filename length
const ext = path.extname(sanitized);
const name = path.basename(sanitized, ext);
if (name.length > 100) {
sanitized = name.substring(0, 100) + ext;
}
return sanitized;
}
/**
* Check for directory traversal attempts
*/
private containsDirectoryTraversal(filename: string): boolean {
return (
filename.includes('../') ||
filename.includes('..\\') ||
filename.includes('..\\') ||
filename.includes('%2e%2e') ||
filename.includes('0x2e0x2e')
);
}
/**
* Check for double extensions (e.g., file.pdf.exe)
*/
private hasDoubleExtension(filename: string): boolean {
const dangerousExtensions = [
'.exe',
'.bat',
'.cmd',
'.com',
'.pif',
'.scr',
'.vbs',
'.js',
'.jar',
'.msi',
'.app',
'.deb',
'.rpm',
];
const lowerFilename = filename.toLowerCase();
return dangerousExtensions.some(ext => lowerFilename.includes(ext));
}
/**
* Basic check if file content matches its extension
*/
private contentMatchesExtension(file: Express.Multer.File): boolean {
const ext = path.extname(file.originalname).toLowerCase();
const buffer = file.buffer;
if (!buffer || buffer.length < 4) {
return false;
}
// Check file signatures (magic numbers)
const signatures: Record<string, number[]> = {
'.pdf': [0x25, 0x50, 0x44, 0x46], // %PDF
'.jpg': [0xff, 0xd8, 0xff],
'.jpeg': [0xff, 0xd8, 0xff],
'.png': [0x89, 0x50, 0x4e, 0x47],
'.webp': [0x52, 0x49, 0x46, 0x46], // RIFF (need to check WEBP later)
'.xlsx': [0x50, 0x4b, 0x03, 0x04], // ZIP format
'.xls': [0xd0, 0xcf, 0x11, 0xe0], // OLE2 format
};
const expectedSignature = signatures[ext];
if (!expectedSignature) {
// For unknown extensions, assume valid (CSV, etc.)
return true;
}
// Check if buffer starts with expected signature
for (let i = 0; i < expectedSignature.length; i++) {
if (buffer[i] !== expectedSignature[i]) {
return false;
}
}
return true;
}
/**
* Scan file for viruses (placeholder for production virus scanning)
*/
async scanForViruses(file: Express.Multer.File): Promise<boolean> {
if (!fileUploadConfig.scanForViruses) {
return true; // Skip in development
}
// TODO: Integrate with ClamAV or similar virus scanner
// For now, just log
this.logger.log(`Virus scan requested for file: ${file.originalname} (not implemented)`);
return true;
}
/**
* Validate multiple files
*/
async validateFiles(files: Express.Multer.File[]): Promise<FileValidationResult> {
const allErrors: string[] = [];
for (const file of files) {
const result = await this.validateFile(file);
if (!result.valid) {
allErrors.push(...result.errors);
}
}
return {
valid: allErrors.length === 0,
errors: allErrors,
};
}
}