const mongoSanitize = require('express-mongo-sanitize'); const xss = require('xss-clean'); const hpp = require('hpp'); const { body, param, query, validationResult } = require('express-validator'); const { BadRequestError } = require('../utils/AppError'); /** * MongoDB NoSQL Injection Prevention * Removes $ and . characters from user input to prevent NoSQL injection */ const sanitizeMongoData = mongoSanitize({ replaceWith: '_', onSanitize: ({ req, key }) => { const logger = require('../config/logger'); logger.logSecurityEvent(`MongoDB injection attempt detected in ${key}`, req); } }); /** * XSS (Cross-Site Scripting) Prevention * Sanitizes user input to prevent XSS attacks */ const sanitizeXSS = xss(); /** * HTTP Parameter Pollution Prevention * Protects against attacks where parameters are sent multiple times */ const preventHPP = hpp({ whitelist: [ // Query parameters that are allowed to have multiple values 'category', 'categoryId', 'difficulty', 'tags', 'keywords', 'sort', 'fields' ] }); /** * Sanitize request body, query, and params * Custom middleware that runs after mongoSanitize and xss */ const sanitizeInput = (req, res, next) => { // Additional sanitization for specific patterns const sanitizeObject = (obj) => { if (!obj || typeof obj !== 'object') return obj; for (const key in obj) { if (typeof obj[key] === 'string') { // Remove null bytes obj[key] = obj[key].replace(/\0/g, ''); // Trim whitespace obj[key] = obj[key].trim(); // Remove any remaining dangerous patterns obj[key] = obj[key] .replace(/)<[^<]*)*<\/script>/gi, '') .replace(/javascript:/gi, '') .replace(/on\w+\s*=/gi, ''); } else if (typeof obj[key] === 'object') { sanitizeObject(obj[key]); } } return obj; }; if (req.body) sanitizeObject(req.body); if (req.query) sanitizeObject(req.query); if (req.params) sanitizeObject(req.params); next(); }; /** * Validate and sanitize email addresses */ const sanitizeEmail = [ body('email') .trim() .toLowerCase() .isEmail().withMessage('Invalid email format') .normalizeEmail({ gmail_remove_dots: false, gmail_remove_subaddress: false, outlookdotcom_remove_subaddress: false, yahoo_remove_subaddress: false, icloud_remove_subaddress: false }) .isLength({ max: 255 }).withMessage('Email too long') ]; /** * Validate and sanitize passwords */ const sanitizePassword = [ body('password') .trim() .isLength({ min: 8 }).withMessage('Password must be at least 8 characters') .isLength({ max: 128 }).withMessage('Password too long') .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/) .withMessage('Password must contain uppercase, lowercase, number and special character') ]; /** * Validate and sanitize usernames */ const sanitizeUsername = [ body('username') .trim() .isLength({ min: 3, max: 30 }).withMessage('Username must be 3-30 characters') .matches(/^[a-zA-Z0-9_-]+$/).withMessage('Username can only contain letters, numbers, underscores and hyphens') ]; /** * Validate and sanitize numeric IDs */ const sanitizeId = [ param('id') .isInt({ min: 1 }).withMessage('Invalid ID') .toInt() ]; /** * Validate and sanitize pagination parameters */ const sanitizePagination = [ query('page') .optional() .isInt({ min: 1, max: 10000 }).withMessage('Invalid page number') .toInt(), query('limit') .optional() .isInt({ min: 1, max: 100 }).withMessage('Limit must be between 1 and 100') .toInt(), query('sort') .optional() .trim() .isIn(['asc', 'desc', 'ASC', 'DESC']).withMessage('Sort must be asc or desc') ]; /** * Validate and sanitize search queries */ const sanitizeSearch = [ query('search') .optional() .trim() .isLength({ max: 200 }).withMessage('Search query too long') .matches(/^[a-zA-Z0-9\s-_.,!?'"]+$/).withMessage('Search contains invalid characters') ]; /** * Validate and sanitize quiz parameters */ const sanitizeQuizParams = [ body('categoryId') .isInt({ min: 1 }).withMessage('Invalid category ID') .toInt(), body('questionCount') .optional() .isInt({ min: 1, max: 50 }).withMessage('Question count must be between 1 and 50') .toInt(), body('difficulty') .optional() .trim() .isIn(['easy', 'medium', 'hard']).withMessage('Invalid difficulty level'), body('quizType') .optional() .trim() .isIn(['practice', 'timed', 'exam']).withMessage('Invalid quiz type') ]; /** * Middleware to check validation results */ const handleValidationErrors = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { const logger = require('../config/logger'); logger.logSecurityEvent('Validation error', req); throw new BadRequestError('Validation failed', errors.array().map(err => ({ field: err.path || err.param, message: err.msg }))); } next(); }; /** * Comprehensive sanitization middleware chain * Use this for all API routes */ const sanitizeAll = [ sanitizeMongoData, sanitizeXSS, preventHPP, sanitizeInput ]; /** * File upload sanitization */ const sanitizeFileUpload = (req, res, next) => { if (req.file) { // Validate file type const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (!allowedMimeTypes.includes(req.file.mimetype)) { return res.status(400).json({ status: 'error', message: 'Invalid file type. Only images are allowed.' }); } // Validate file size (5MB max) if (req.file.size > 5 * 1024 * 1024) { return res.status(400).json({ status: 'error', message: 'File too large. Maximum size is 5MB.' }); } // Sanitize filename req.file.originalname = req.file.originalname .replace(/[^a-zA-Z0-9.-]/g, '_') .substring(0, 100); } next(); }; module.exports = { // Core sanitization middleware sanitizeMongoData, sanitizeXSS, preventHPP, sanitizeInput, sanitizeAll, // Specific field validators sanitizeEmail, sanitizePassword, sanitizeUsername, sanitizeId, sanitizePagination, sanitizeSearch, sanitizeQuizParams, // Validation handler handleValidationErrors, // File upload sanitization sanitizeFileUpload };