Files
Tasks/backend/middleware/sanitization.js
2025-11-12 23:06:27 +02:00

263 lines
6.4 KiB
JavaScript

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\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/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
};