add changes
This commit is contained in:
267
backend/middleware/cache.js
Normal file
267
backend/middleware/cache.js
Normal file
@@ -0,0 +1,267 @@
|
||||
const { getCache, setCache, deleteCache } = require('../config/redis');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Cache middleware for GET requests
|
||||
* @param {number} ttl - Time to live in seconds (default: 300 = 5 minutes)
|
||||
* @param {function} keyGenerator - Function to generate cache key from req
|
||||
*/
|
||||
const cacheMiddleware = (ttl = 300, keyGenerator = null) => {
|
||||
return async (req, res, next) => {
|
||||
// Only cache GET requests
|
||||
if (req.method !== 'GET') {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate cache key
|
||||
const cacheKey = keyGenerator
|
||||
? keyGenerator(req)
|
||||
: `cache:${req.originalUrl}`;
|
||||
|
||||
// Try to get from cache
|
||||
const cachedData = await getCache(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
logger.debug(`Cache hit for: ${cacheKey}`);
|
||||
return res.status(200).json(cachedData);
|
||||
}
|
||||
|
||||
// Cache miss - store original json method
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// Override json method to cache response
|
||||
res.json = function(data) {
|
||||
// Only cache successful responses
|
||||
if (res.statusCode === 200) {
|
||||
setCache(cacheKey, data, ttl).catch(err => {
|
||||
logger.error('Failed to cache response:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Call original json method
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Cache middleware error:', error);
|
||||
next(); // Continue even if cache fails
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cache categories (rarely change)
|
||||
*/
|
||||
const cacheCategories = cacheMiddleware(3600, (req) => {
|
||||
// Cache for 1 hour
|
||||
return 'cache:categories:list';
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache single category
|
||||
*/
|
||||
const cacheSingleCategory = cacheMiddleware(3600, (req) => {
|
||||
return `cache:category:${req.params.id}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache guest settings (rarely change)
|
||||
*/
|
||||
const cacheGuestSettings = cacheMiddleware(1800, (req) => {
|
||||
// Cache for 30 minutes
|
||||
return 'cache:guest:settings';
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache system statistics (update frequently)
|
||||
*/
|
||||
const cacheStatistics = cacheMiddleware(300, (req) => {
|
||||
// Cache for 5 minutes
|
||||
return 'cache:admin:statistics';
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache guest analytics
|
||||
*/
|
||||
const cacheGuestAnalytics = cacheMiddleware(600, (req) => {
|
||||
// Cache for 10 minutes
|
||||
return 'cache:admin:guest-analytics';
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache user dashboard
|
||||
*/
|
||||
const cacheUserDashboard = cacheMiddleware(300, (req) => {
|
||||
// Cache for 5 minutes
|
||||
const userId = req.params.userId || req.user?.id;
|
||||
return `cache:user:${userId}:dashboard`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache questions list (with filters)
|
||||
*/
|
||||
const cacheQuestions = cacheMiddleware(600, (req) => {
|
||||
// Cache for 10 minutes
|
||||
const { categoryId, difficulty, questionType, visibility } = req.query;
|
||||
const filters = [categoryId, difficulty, questionType, visibility]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
return `cache:questions:${filters || 'all'}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache single question
|
||||
*/
|
||||
const cacheSingleQuestion = cacheMiddleware(1800, (req) => {
|
||||
return `cache:question:${req.params.id}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache user bookmarks
|
||||
*/
|
||||
const cacheUserBookmarks = cacheMiddleware(300, (req) => {
|
||||
const userId = req.params.userId || req.user?.id;
|
||||
return `cache:user:${userId}:bookmarks`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache user history
|
||||
*/
|
||||
const cacheUserHistory = cacheMiddleware(300, (req) => {
|
||||
const userId = req.params.userId || req.user?.id;
|
||||
const page = req.query.page || 1;
|
||||
return `cache:user:${userId}:history:page:${page}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Invalidate cache patterns
|
||||
*/
|
||||
const invalidateCache = {
|
||||
/**
|
||||
* Invalidate user-related cache
|
||||
*/
|
||||
user: async (userId) => {
|
||||
await deleteCache(`cache:user:${userId}:*`);
|
||||
logger.debug(`Invalidated cache for user ${userId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate category cache
|
||||
*/
|
||||
category: async (categoryId = null) => {
|
||||
if (categoryId) {
|
||||
await deleteCache(`cache:category:${categoryId}`);
|
||||
}
|
||||
await deleteCache('cache:categories:*');
|
||||
logger.debug('Invalidated category cache');
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate question cache
|
||||
*/
|
||||
question: async (questionId = null) => {
|
||||
if (questionId) {
|
||||
await deleteCache(`cache:question:${questionId}`);
|
||||
}
|
||||
await deleteCache('cache:questions:*');
|
||||
logger.debug('Invalidated question cache');
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate statistics cache
|
||||
*/
|
||||
statistics: async () => {
|
||||
await deleteCache('cache:admin:statistics');
|
||||
await deleteCache('cache:admin:guest-analytics');
|
||||
logger.debug('Invalidated statistics cache');
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate guest settings cache
|
||||
*/
|
||||
guestSettings: async () => {
|
||||
await deleteCache('cache:guest:settings');
|
||||
logger.debug('Invalidated guest settings cache');
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate all quiz-related cache
|
||||
*/
|
||||
quiz: async (userId = null, guestId = null) => {
|
||||
if (userId) {
|
||||
await deleteCache(`cache:user:${userId}:*`);
|
||||
}
|
||||
if (guestId) {
|
||||
await deleteCache(`cache:guest:${guestId}:*`);
|
||||
}
|
||||
await invalidateCache.statistics();
|
||||
logger.debug('Invalidated quiz cache');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to invalidate cache after mutations
|
||||
*/
|
||||
const invalidateCacheMiddleware = (pattern) => {
|
||||
return async (req, res, next) => {
|
||||
// Store original json method
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// Override json method
|
||||
res.json = async function(data) {
|
||||
// Only invalidate on successful mutations
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
if (typeof pattern === 'function') {
|
||||
await pattern(req);
|
||||
} else {
|
||||
await deleteCache(pattern);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Cache invalidation error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Call original json method
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cache warming - preload frequently accessed data
|
||||
*/
|
||||
const warmCache = async () => {
|
||||
try {
|
||||
logger.info('Warming cache...');
|
||||
|
||||
// This would typically fetch and cache common data
|
||||
// For now, we'll just log the intent
|
||||
// In a real scenario, you'd fetch categories, popular questions, etc.
|
||||
|
||||
logger.info('Cache warming complete');
|
||||
} catch (error) {
|
||||
logger.error('Cache warming error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
cacheMiddleware,
|
||||
cacheCategories,
|
||||
cacheSingleCategory,
|
||||
cacheGuestSettings,
|
||||
cacheStatistics,
|
||||
cacheGuestAnalytics,
|
||||
cacheUserDashboard,
|
||||
cacheQuestions,
|
||||
cacheSingleQuestion,
|
||||
cacheUserBookmarks,
|
||||
cacheUserHistory,
|
||||
invalidateCache,
|
||||
invalidateCacheMiddleware,
|
||||
warmCache
|
||||
};
|
||||
248
backend/middleware/errorHandler.js
Normal file
248
backend/middleware/errorHandler.js
Normal file
@@ -0,0 +1,248 @@
|
||||
const logger = require('../config/logger');
|
||||
const { AppError } = require('../utils/AppError');
|
||||
|
||||
/**
|
||||
* Handle Sequelize validation errors
|
||||
*/
|
||||
const handleSequelizeValidationError = (error) => {
|
||||
const errors = error.errors.map(err => ({
|
||||
field: err.path,
|
||||
message: err.message,
|
||||
value: err.value
|
||||
}));
|
||||
|
||||
return {
|
||||
statusCode: 400,
|
||||
message: 'Validation error',
|
||||
errors
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Sequelize unique constraint errors
|
||||
*/
|
||||
const handleSequelizeUniqueConstraintError = (error) => {
|
||||
const field = error.errors[0]?.path;
|
||||
const value = error.errors[0]?.value;
|
||||
|
||||
return {
|
||||
statusCode: 409,
|
||||
message: `${field} '${value}' already exists`
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Sequelize foreign key constraint errors
|
||||
*/
|
||||
const handleSequelizeForeignKeyConstraintError = (error) => {
|
||||
return {
|
||||
statusCode: 400,
|
||||
message: 'Invalid reference to related resource'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Sequelize database connection errors
|
||||
*/
|
||||
const handleSequelizeConnectionError = (error) => {
|
||||
return {
|
||||
statusCode: 503,
|
||||
message: 'Database connection error. Please try again later.'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle JWT errors
|
||||
*/
|
||||
const handleJWTError = () => {
|
||||
return {
|
||||
statusCode: 401,
|
||||
message: 'Invalid token. Please log in again.'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle JWT expired errors
|
||||
*/
|
||||
const handleJWTExpiredError = () => {
|
||||
return {
|
||||
statusCode: 401,
|
||||
message: 'Your token has expired. Please log in again.'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Sequelize errors
|
||||
*/
|
||||
const handleSequelizeError = (error) => {
|
||||
// Validation error
|
||||
if (error.name === 'SequelizeValidationError') {
|
||||
return handleSequelizeValidationError(error);
|
||||
}
|
||||
|
||||
// Unique constraint violation
|
||||
if (error.name === 'SequelizeUniqueConstraintError') {
|
||||
return handleSequelizeUniqueConstraintError(error);
|
||||
}
|
||||
|
||||
// Foreign key constraint violation
|
||||
if (error.name === 'SequelizeForeignKeyConstraintError') {
|
||||
return handleSequelizeForeignKeyConstraintError(error);
|
||||
}
|
||||
|
||||
// Database connection error
|
||||
if (error.name === 'SequelizeConnectionError' ||
|
||||
error.name === 'SequelizeConnectionRefusedError' ||
|
||||
error.name === 'SequelizeHostNotFoundError' ||
|
||||
error.name === 'SequelizeAccessDeniedError') {
|
||||
return handleSequelizeConnectionError(error);
|
||||
}
|
||||
|
||||
// Generic database error
|
||||
return {
|
||||
statusCode: 500,
|
||||
message: 'Database error occurred'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Send error response in development
|
||||
*/
|
||||
const sendErrorDev = (err, res) => {
|
||||
res.status(err.statusCode).json({
|
||||
status: err.status,
|
||||
message: err.message,
|
||||
error: err,
|
||||
stack: err.stack,
|
||||
...(err.errors && { errors: err.errors })
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Send error response in production
|
||||
*/
|
||||
const sendErrorProd = (err, res) => {
|
||||
// Operational, trusted error: send message to client
|
||||
if (err.isOperational) {
|
||||
res.status(err.statusCode).json({
|
||||
status: err.status,
|
||||
message: err.message,
|
||||
...(err.errors && { errors: err.errors })
|
||||
});
|
||||
}
|
||||
// Programming or unknown error: don't leak error details
|
||||
else {
|
||||
// Log error for debugging
|
||||
logger.error('ERROR 💥', err);
|
||||
|
||||
// Send generic message
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: 'Something went wrong. Please try again later.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Centralized error handling middleware
|
||||
*/
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
err.statusCode = err.statusCode || 500;
|
||||
err.status = err.status || 'error';
|
||||
|
||||
// Log the error
|
||||
logger.logError(err, req);
|
||||
|
||||
// Handle specific error types
|
||||
let error = { ...err };
|
||||
error.message = err.message;
|
||||
error.stack = err.stack;
|
||||
|
||||
// Sequelize errors
|
||||
if (err.name && err.name.startsWith('Sequelize')) {
|
||||
const handled = handleSequelizeError(err);
|
||||
error.statusCode = handled.statusCode;
|
||||
error.message = handled.message;
|
||||
error.isOperational = true;
|
||||
if (handled.errors) error.errors = handled.errors;
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
const handled = handleJWTError();
|
||||
error.statusCode = handled.statusCode;
|
||||
error.message = handled.message;
|
||||
error.isOperational = true;
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
const handled = handleJWTExpiredError();
|
||||
error.statusCode = handled.statusCode;
|
||||
error.message = handled.message;
|
||||
error.isOperational = true;
|
||||
}
|
||||
|
||||
// Multer errors (file upload)
|
||||
if (err.name === 'MulterError') {
|
||||
error.statusCode = 400;
|
||||
error.message = `File upload error: ${err.message}`;
|
||||
error.isOperational = true;
|
||||
}
|
||||
|
||||
// Send error response
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
sendErrorDev(error, res);
|
||||
} else {
|
||||
sendErrorProd(error, res);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle async errors (wrap async route handlers)
|
||||
*/
|
||||
const catchAsync = (fn) => {
|
||||
return (req, res, next) => {
|
||||
fn(req, res, next).catch(next);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle 404 Not Found errors
|
||||
*/
|
||||
const notFoundHandler = (req, res, next) => {
|
||||
const error = new AppError(
|
||||
`Cannot find ${req.originalUrl} on this server`,
|
||||
404
|
||||
);
|
||||
next(error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Log unhandled rejections
|
||||
*/
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled Rejection at:', {
|
||||
promise,
|
||||
reason: reason.stack || reason
|
||||
});
|
||||
// Optional: Exit process in production
|
||||
// process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Log uncaught exceptions
|
||||
*/
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught Exception:', {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
// Exit process on uncaught exception
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
errorHandler,
|
||||
catchAsync,
|
||||
notFoundHandler
|
||||
};
|
||||
150
backend/middleware/rateLimiter.js
Normal file
150
backend/middleware/rateLimiter.js
Normal file
@@ -0,0 +1,150 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Create a custom rate limit handler
|
||||
*/
|
||||
const createRateLimitHandler = (req, res) => {
|
||||
logger.logSecurityEvent('Rate limit exceeded', req);
|
||||
|
||||
res.status(429).json({
|
||||
status: 'error',
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
retryAfter: res.getHeader('Retry-After')
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* General API rate limiter
|
||||
* 100 requests per 15 minutes per IP
|
||||
*/
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // Limit each IP to 100 requests per windowMs
|
||||
message: 'Too many requests from this IP, please try again after 15 minutes',
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
handler: createRateLimitHandler,
|
||||
skip: (req) => {
|
||||
// Skip rate limiting for health check endpoint
|
||||
return req.path === '/health';
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Strict rate limiter for authentication endpoints
|
||||
* 5 requests per 15 minutes per IP
|
||||
*/
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // Limit each IP to 5 requests per windowMs
|
||||
message: 'Too many authentication attempts, please try again after 15 minutes',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler,
|
||||
skipSuccessfulRequests: false // Count all requests, including successful ones
|
||||
});
|
||||
|
||||
/**
|
||||
* Rate limiter for login attempts
|
||||
* More restrictive - 5 attempts per 15 minutes
|
||||
*/
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5,
|
||||
message: 'Too many login attempts, please try again after 15 minutes',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler,
|
||||
skipFailedRequests: false // Count both successful and failed attempts
|
||||
});
|
||||
|
||||
/**
|
||||
* Rate limiter for registration
|
||||
* 3 registrations per hour per IP
|
||||
*/
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 3,
|
||||
message: 'Too many accounts created from this IP, please try again after an hour',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler
|
||||
});
|
||||
|
||||
/**
|
||||
* Rate limiter for password reset
|
||||
* 3 requests per hour per IP
|
||||
*/
|
||||
const passwordResetLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
message: 'Too many password reset attempts, please try again after an hour',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler
|
||||
});
|
||||
|
||||
/**
|
||||
* Rate limiter for quiz creation
|
||||
* 30 quizzes per hour per user
|
||||
*/
|
||||
const quizLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 30,
|
||||
message: 'Too many quizzes started, please try again later',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler
|
||||
});
|
||||
|
||||
/**
|
||||
* Rate limiter for admin operations
|
||||
* 100 requests per 15 minutes
|
||||
*/
|
||||
const adminLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
message: 'Too many admin requests, please try again later',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler
|
||||
});
|
||||
|
||||
/**
|
||||
* Rate limiter for guest session creation
|
||||
* 5 guest sessions per hour per IP
|
||||
*/
|
||||
const guestSessionLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
message: 'Too many guest sessions created, please try again later',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler
|
||||
});
|
||||
|
||||
/**
|
||||
* Rate limiter for API documentation
|
||||
* Prevent abuse of documentation endpoint
|
||||
*/
|
||||
const docsLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 50,
|
||||
message: 'Too many requests to documentation, please try again later',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: createRateLimitHandler
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
apiLimiter,
|
||||
authLimiter,
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
passwordResetLimiter,
|
||||
quizLimiter,
|
||||
adminLimiter,
|
||||
guestSessionLimiter,
|
||||
docsLimiter
|
||||
};
|
||||
262
backend/middleware/sanitization.js
Normal file
262
backend/middleware/sanitization.js
Normal file
@@ -0,0 +1,262 @@
|
||||
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
|
||||
};
|
||||
155
backend/middleware/security.js
Normal file
155
backend/middleware/security.js
Normal file
@@ -0,0 +1,155 @@
|
||||
const helmet = require('helmet');
|
||||
|
||||
/**
|
||||
* Helmet security configuration
|
||||
* Helmet helps secure Express apps by setting various HTTP headers
|
||||
*/
|
||||
const helmetConfig = helmet({
|
||||
// Content Security Policy
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for Swagger UI
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts for Swagger UI
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'none'"]
|
||||
}
|
||||
},
|
||||
|
||||
// Cross-Origin-Embedder-Policy
|
||||
crossOriginEmbedderPolicy: false, // Disabled for API compatibility
|
||||
|
||||
// Cross-Origin-Opener-Policy
|
||||
crossOriginOpenerPolicy: { policy: "same-origin" },
|
||||
|
||||
// Cross-Origin-Resource-Policy
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
|
||||
// DNS Prefetch Control
|
||||
dnsPrefetchControl: { allow: false },
|
||||
|
||||
// Expect-CT (deprecated but included for older browsers)
|
||||
expectCt: { maxAge: 86400 },
|
||||
|
||||
// Frameguard (prevent clickjacking)
|
||||
frameguard: { action: "deny" },
|
||||
|
||||
// Hide Powered-By header
|
||||
hidePoweredBy: true,
|
||||
|
||||
// HTTP Strict Transport Security
|
||||
hsts: {
|
||||
maxAge: 31536000, // 1 year
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
},
|
||||
|
||||
// IE No Open
|
||||
ieNoOpen: true,
|
||||
|
||||
// No Sniff (prevent MIME type sniffing)
|
||||
noSniff: true,
|
||||
|
||||
// Origin-Agent-Cluster
|
||||
originAgentCluster: true,
|
||||
|
||||
// Permitted Cross-Domain Policies
|
||||
permittedCrossDomainPolicies: { permittedPolicies: "none" },
|
||||
|
||||
// Referrer Policy
|
||||
referrerPolicy: { policy: "no-referrer" }
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom security headers middleware
|
||||
* Only adds headers not already set by Helmet
|
||||
*/
|
||||
const customSecurityHeaders = (req, res, next) => {
|
||||
// Add Permissions-Policy (not in Helmet)
|
||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
|
||||
// Prevent caching of sensitive data
|
||||
if (req.path.includes('/api/auth') || req.path.includes('/api/admin') || req.path.includes('/api/users')) {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
res.setHeader('Surrogate-Control', 'no-store');
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* CORS configuration
|
||||
*/
|
||||
const getCorsOptions = () => {
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',')
|
||||
: ['http://localhost:3000', 'http://localhost:4200', 'http://localhost:5173'];
|
||||
|
||||
return {
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (mobile apps, Postman, etc.)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
if (allowedOrigins.indexOf(origin) !== -1 || process.env.NODE_ENV === 'development') {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'x-guest-token'],
|
||||
exposedHeaders: ['RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset'],
|
||||
maxAge: 86400 // 24 hours
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Security middleware for API routes
|
||||
*/
|
||||
const secureApiRoutes = (req, res, next) => {
|
||||
// Log security-sensitive operations
|
||||
if (req.method !== 'GET' && req.path.includes('/api/admin')) {
|
||||
const logger = require('../config/logger');
|
||||
logger.logSecurityEvent(`Admin ${req.method} request`, req);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Prevent parameter pollution
|
||||
* This middleware should be used after body parser
|
||||
*/
|
||||
const preventParameterPollution = (req, res, next) => {
|
||||
// Whitelist of parameters that can have multiple values
|
||||
const whitelist = ['category', 'difficulty', 'tags', 'keywords'];
|
||||
|
||||
// Check for duplicate parameters
|
||||
if (req.query) {
|
||||
for (const param in req.query) {
|
||||
if (Array.isArray(req.query[param]) && !whitelist.includes(param)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: `Parameter pollution detected: '${param}' should not have multiple values`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
helmetConfig,
|
||||
customSecurityHeaders,
|
||||
getCorsOptions,
|
||||
secureApiRoutes,
|
||||
preventParameterPollution
|
||||
};
|
||||
Reference in New Issue
Block a user