const path = require('path'); const fs = require('fs').promises; /** * Secure file security utilities to prevent path traversal and validate file types */ /** * Safely join paths and prevent directory traversal attacks * @param {string} basePath - The base directory path * @param {string} userPath - The user-provided path to join * @returns {string} - Safe joined path * @throws {Error} - If path traversal is detected */ function safePathJoin(basePath, userPath) { // Normalize the base path const normalizedBase = path.resolve(basePath); // Join and resolve the full path const joinedPath = path.join(normalizedBase, userPath); const resolvedPath = path.resolve(joinedPath); // Ensure the resolved path starts with the base path if (!resolvedPath.startsWith(normalizedBase + path.sep) && resolvedPath !== normalizedBase) { throw new Error('Path traversal attempt detected'); } return resolvedPath; } /** * Validate file path to prevent directory traversal * @param {string} filePath - The file path to validate * @returns {boolean} - True if path is safe */ function isPathSafe(filePath) { // Check for common path traversal patterns const dangerousPatterns = [ /\.\.[\/\\]/, // ../ or ..\ /^[A-Za-z]:/, // Windows drive letters /[\x00-\x1f]/ // Control characters ]; return !dangerousPatterns.some(pattern => pattern.test(filePath)); } /** * Enhanced MIME type validation */ const ALLOWED_IMAGE_TYPES = { 'image/jpeg': { extensions: ['.jpg', '.jpeg'], magicNumbers: [ { offset: 0, bytes: [0xFF, 0xD8, 0xFF] } // JPEG ] }, 'image/png': { extensions: ['.png'], magicNumbers: [ { offset: 0, bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] } // PNG ] }, 'image/webp': { extensions: ['.webp'], magicNumbers: [ { offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] } // WEBP ] }, 'image/gif': { extensions: ['.gif'], magicNumbers: [ { offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] }, // GIF87a { offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] } // GIF89a ] }, 'image/svg+xml': { extensions: ['.svg'], // SVG files are XML-based text files, so we skip magic number validation magicNumbers: null } }; /** * Validate file type by MIME type and extension * @param {string} filename - The filename * @param {string} mimetype - The MIME type * @param {string[]} allowedTypes - Array of allowed MIME types * @returns {boolean} - True if file type is valid */ function validateFileType(filename, mimetype, allowedTypes) { // Check if MIME type is allowed if (!allowedTypes.includes(mimetype)) { return false; } // Get file extension const ext = path.extname(filename).toLowerCase(); // Check if extension matches the MIME type const typeConfig = ALLOWED_IMAGE_TYPES[mimetype]; if (!typeConfig || !typeConfig.extensions.includes(ext)) { return false; } return true; } /** * Validate file content by checking magic numbers (file signatures) * @param {string} filePath - Path to the file * @param {string} expectedMimeType - Expected MIME type * @returns {Promise} - True if file content matches expected type */ async function validateFileContent(filePath, expectedMimeType) { try { const typeConfig = ALLOWED_IMAGE_TYPES[expectedMimeType]; if (!typeConfig) { return false; } // Skip validation for file types without magic numbers (like SVG) if (!typeConfig.magicNumbers) { return true; } // Read the first 20 bytes of the file (enough for most magic numbers) const buffer = Buffer.alloc(20); const fileHandle = await fs.open(filePath, 'r'); await fileHandle.read(buffer, 0, 20, 0); await fileHandle.close(); // Check magic numbers return typeConfig.magicNumbers.every(magic => { for (let i = 0; i < magic.bytes.length; i++) { if (buffer[magic.offset + i] !== magic.bytes[i]) { return false; } } return true; }); } catch (error) { console.error('Error validating file content:', error); return false; } } /** * Get safe filename for storage * @param {string} originalFilename - Original filename * @returns {string} - Safe filename */ function getSafeFilename(originalFilename) { const timestamp = Date.now(); const randomString = Math.random().toString(36).substring(2, 15); const ext = path.extname(originalFilename).toLowerCase(); // Validate extension const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico']; if (!validExtensions.includes(ext)) { throw new Error('Invalid file extension'); } return `upload_${timestamp}_${randomString}${ext}`; } /** * Create a file upload validator middleware * @param {Object} options - Validation options * @returns {Function} - Express middleware function */ function createFileUploadValidator(options = {}) { const { allowedTypes = ['image/jpeg', 'image/png', 'image/webp'], maxFileSize = 50 * 1024 * 1024, // 50MB default validateContent = true } = options; return async (req, res, next) => { try { if (!req.files || req.files.length === 0) { return next(); } for (const file of req.files) { // Validate file type if (!validateFileType(file.originalname, file.mimetype, allowedTypes)) { return res.status(400).json({ error: `Invalid file type: ${file.originalname}. Allowed types: ${allowedTypes.join(', ')}` }); } // Validate file size if (file.size > maxFileSize) { return res.status(400).json({ error: `File too large: ${file.originalname}. Maximum size: ${maxFileSize / 1024 / 1024}MB` }); } // Validate file content if enabled if (validateContent && file.path) { const isValidContent = await validateFileContent(file.path, file.mimetype); if (!isValidContent) { // Remove the file if content doesn't match try { await fs.unlink(file.path); } catch (err) { console.error('Error removing invalid file:', err); } return res.status(400).json({ error: `File content does not match declared type: ${file.originalname}` }); } } } next(); } catch (error) { console.error('File validation error:', error); res.status(500).json({ error: 'File validation failed' }); } }; } module.exports = { safePathJoin, isPathSafe, validateFileType, validateFileContent, getSafeFilename, createFileUploadValidator, ALLOWED_IMAGE_TYPES };