211 lines
5.5 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|