263 lines
6.4 KiB
JavaScript
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
|
|
};
|