UNPKG

@mvp-factory/holy-upload

Version:

File upload processing system extracted from Holy Habit project with security validation and image optimization

367 lines 13 kB
"use strict"; /** * File Validator * * Validates uploaded files for security and compliance * Extracted from Holy Habit upload system with enhanced security */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.FileValidator = void 0; const Upload_1 = require("../types/Upload"); const mimeTypes = __importStar(require("mime-types")); const fs = __importStar(require("fs")); const path = __importStar(require("path")); class FileValidator { /** * Validate uploaded file * * @param file - Multer file object * @param options - Validation options * @returns Validation result */ static async validate(file, options = {}) { const errors = []; let mimeType; let extension; try { // Basic file checks if (!file) { errors.push('No file provided'); return { isValid: false, errors }; } if (!file.buffer && !file.path) { errors.push('File data not available'); return { isValid: false, errors }; } // File size validation if (options.maxSize && file.size > options.maxSize) { errors.push(`File size ${this.formatBytes(file.size)} exceeds limit of ${this.formatBytes(options.maxSize)}`); } // Get file extension extension = this.getFileExtension(file.originalname); if (!extension) { errors.push('File has no extension'); } // Extension validation if (options.allowedExtensions && extension) { if (!options.allowedExtensions.includes(extension.toLowerCase())) { errors.push(`File extension '${extension}' is not allowed`); } } // MIME type detection and validation mimeType = await this.detectMimeType(file); if (!mimeType) { errors.push('Could not determine file type'); } else if (options.allowedMimeTypes) { if (!options.allowedMimeTypes.includes(mimeType)) { errors.push(`File type '${mimeType}' is not allowed`); } } // Magic number validation for images if (mimeType && this.isImageType(mimeType)) { const magicValid = await this.validateMagicNumber(file, mimeType); if (!magicValid) { errors.push('File header does not match declared type'); } } // Malicious content detection if (options.checkMalicious !== false) { const maliciousCheck = await this.checkMaliciousContent(file); if (!maliciousCheck.isClean) { errors.push(`Potentially malicious content detected: ${maliciousCheck.reason}`); } } // Filename validation const filenameValid = this.validateFilename(file.originalname); if (!filenameValid.isValid) { errors.push(...filenameValid.errors); } } catch (error) { errors.push(`Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`); } return { isValid: errors.length === 0, mimeType, extension, size: file.size, errors }; } /** * Detect MIME type using multiple methods * * @param file - Multer file object * @returns Detected MIME type */ static async detectMimeType(file) { let mimeType; // Method 1: Use provided MIME type if available if (file.mimetype) { mimeType = file.mimetype; } // Method 2: Detect from file extension if (!mimeType || mimeType === 'application/octet-stream') { const detectedType = mimeTypes.lookup(file.originalname); if (detectedType) { mimeType = detectedType; } } // Method 3: Magic number detection for images if (file.buffer || file.path) { const magicType = await this.detectFromMagicNumber(file); if (magicType) { // Prefer magic number detection for security mimeType = magicType; } } return mimeType; } /** * Detect MIME type from magic number * * @param file - Multer file object * @returns Detected MIME type */ static async detectFromMagicNumber(file) { try { let buffer; if (file.buffer) { buffer = file.buffer.slice(0, 16); // First 16 bytes } else if (file.path) { const fd = await fs.promises.open(file.path, 'r'); const { buffer: readBuffer } = await fd.read(Buffer.alloc(16), 0, 16, 0); await fd.close(); buffer = readBuffer; } else { return undefined; } const hex = buffer.toString('hex').toUpperCase(); for (const [mimeType, signatures] of Object.entries(this.MAGIC_NUMBERS)) { for (const signature of signatures) { if (hex.startsWith(signature)) { return mimeType; } } } return undefined; } catch (error) { return undefined; } } /** * Validate magic number against MIME type * * @param file - Multer file object * @param expectedMimeType - Expected MIME type * @returns True if magic number matches */ static async validateMagicNumber(file, expectedMimeType) { const detectedType = await this.detectFromMagicNumber(file); return detectedType === expectedMimeType; } /** * Check for malicious content * * @param file - Multer file object * @returns Malicious check result */ static async checkMaliciousContent(file) { try { let content; // Read file content if (file.buffer) { content = file.buffer.toString('utf8', 0, Math.min(file.buffer.length, 8192)); // First 8KB } else if (file.path) { const buffer = Buffer.alloc(8192); const fd = await fs.promises.open(file.path, 'r'); const { bytesRead } = await fd.read(buffer, 0, 8192, 0); await fd.close(); content = buffer.toString('utf8', 0, bytesRead); } else { return { isClean: true }; } // Check against malicious patterns for (const pattern of this.MALICIOUS_PATTERNS) { if (pattern.test(content)) { return { isClean: false, reason: 'Suspicious script content detected' }; } } // Check filename for executable extensions if (/\.(exe|bat|cmd|scr|pif|com|php|jsp|asp)$/i.test(file.originalname)) { return { isClean: false, reason: 'Executable file extension detected' }; } return { isClean: true }; } catch (error) { // If we can't read the file, assume it's suspicious return { isClean: false, reason: 'Unable to scan file content' }; } } /** * Validate filename * * @param filename - Original filename * @returns Validation result */ static validateFilename(filename) { const errors = []; if (!filename) { errors.push('Filename is required'); return { isValid: false, errors }; } // Check for path traversal attempts if (filename.includes('../') || filename.includes('..\\')) { errors.push('Filename contains path traversal characters'); } // Check for invalid characters const invalidChars = /[<>:"|*?]/; if (invalidChars.test(filename)) { errors.push('Filename contains invalid characters'); } // Check for reserved names (Windows) const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i; if (reservedNames.test(filename)) { errors.push('Filename uses reserved system name'); } // Check for control characters if (/[\x00-\x1f\x80-\x9f]/.test(filename)) { errors.push('Filename contains control characters'); } // Check length if (filename.length > 255) { errors.push('Filename is too long (max 255 characters)'); } return { isValid: errors.length === 0, errors }; } /** * Get file extension * * @param filename - Filename * @returns File extension (without dot) */ static getFileExtension(filename) { const extension = path.extname(filename).toLowerCase().slice(1); return extension || undefined; } /** * Check if MIME type is an image * * @param mimeType - MIME type * @returns True if image type */ static isImageType(mimeType) { return mimeType.startsWith('image/'); } /** * Format bytes to human readable format * * @param bytes - Bytes * @returns Formatted string */ static formatBytes(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } /** * Create validation error * * @param result - Validation result * @returns Validation error */ static createError(result) { const message = result.errors.join(', '); if (result.errors.some(e => e.includes('size'))) { return new Upload_1.FileSizeError(message); } if (result.errors.some(e => e.includes('type') || e.includes('extension'))) { return new Upload_1.FileTypeError(message); } return new Upload_1.ValidationError(message); } /** * Quick validation for common cases * * @param file - Multer file object * @param maxSize - Maximum file size * @param allowedTypes - Allowed MIME types * @returns True if valid */ static async quickValidate(file, maxSize = 10 * 1024 * 1024, allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']) { const result = await this.validate(file, { maxSize, allowedMimeTypes: allowedTypes, checkMalicious: true }); return result.isValid; } } exports.FileValidator = FileValidator; FileValidator.MAGIC_NUMBERS = { 'image/jpeg': ['FFD8FF'], 'image/png': ['89504E47'], 'image/gif': ['474946383761', '474946383961'], // GIF87a, GIF89a 'image/webp': ['52494646'] }; FileValidator.MALICIOUS_PATTERNS = [ // Script injections /<script[^>]*>/i, /javascript:/i, /vbscript:/i, /onload=/i, /onerror=/i, // PHP tags /<\?php/i, /<\?=/i, // Server-side includes /<!--#exec/i, /<!--#include/i, // Executable extensions in filename /\.(exe|bat|cmd|scr|pif|com)$/i ]; //# sourceMappingURL=FileValidator.js.map