refactor: enhance middleware and security with advanced protection mechanisms
- Upgraded rate limiter configuration with more granular control and detailed headers - Improved authentication middleware with enhanced token validation and error responses - Implemented advanced input sanitization using sanitize-html with comprehensive XSS protection - Replaced manual security headers with helmet for robust web security configuration - Enhanced error handling middleware with more detailed logging and specific error type handling - Updated SSE rate limiting with burst and window-based restrictions - Improved token validation with more precise signature and claim verification
This commit is contained in:
@@ -2,32 +2,52 @@ import { Request, Response, NextFunction } from 'express';
|
||||
import { HASS_CONFIG, RATE_LIMIT_CONFIG } from '../config/index.js';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { TokenManager } from '../security/index.js';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import helmet from 'helmet';
|
||||
|
||||
// Rate limiter middleware
|
||||
// Rate limiter middleware with enhanced configuration
|
||||
export const rateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: RATE_LIMIT_CONFIG.REGULAR,
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
message: {
|
||||
success: false,
|
||||
message: 'Too many requests, please try again later.',
|
||||
reset_time: new Date(Date.now() + 60 * 1000).toISOString()
|
||||
}
|
||||
},
|
||||
skipSuccessfulRequests: false, // Count all requests
|
||||
keyGenerator: (req) => req.ip || req.socket.remoteAddress || 'unknown' // Use IP for rate limiting
|
||||
});
|
||||
|
||||
// WebSocket rate limiter middleware
|
||||
// WebSocket rate limiter middleware with enhanced configuration
|
||||
export const wsRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: RATE_LIMIT_CONFIG.WEBSOCKET,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
success: false,
|
||||
message: 'Too many WebSocket connections, please try again later.',
|
||||
reset_time: new Date(Date.now() + 60 * 1000).toISOString()
|
||||
}
|
||||
},
|
||||
skipSuccessfulRequests: false,
|
||||
keyGenerator: (req) => req.ip || req.socket.remoteAddress || 'unknown'
|
||||
});
|
||||
|
||||
// Authentication middleware
|
||||
// Authentication middleware with enhanced security
|
||||
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '') || '';
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
error: 'Missing or invalid authorization header',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const clientIp = req.ip || req.socket.remoteAddress || '';
|
||||
|
||||
const validationResult = TokenManager.validateToken(token, clientIp);
|
||||
@@ -44,18 +64,40 @@ export const authenticate = (req: Request, res: Response, next: NextFunction) =>
|
||||
next();
|
||||
};
|
||||
|
||||
// Enhanced security headers middleware
|
||||
export const securityHeaders = (_req: Request, res: Response, next: NextFunction) => {
|
||||
// Set strict security headers
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https:;");
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
next();
|
||||
};
|
||||
// Enhanced security headers middleware using helmet
|
||||
export const securityHeaders = helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'", 'wss:', 'https:'],
|
||||
frameSrc: ["'none'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'none'"]
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: true,
|
||||
crossOriginOpenerPolicy: { policy: 'same-origin' },
|
||||
crossOriginResourcePolicy: { policy: 'same-origin' },
|
||||
dnsPrefetchControl: { allow: false },
|
||||
frameguard: { action: 'deny' },
|
||||
hidePoweredBy: true,
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
},
|
||||
ieNoOpen: true,
|
||||
noSniff: true,
|
||||
originAgentCluster: true,
|
||||
permittedCrossDomainPolicies: { permittedPolicies: 'none' },
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
xssFilter: true
|
||||
});
|
||||
|
||||
// Enhanced request validation middleware
|
||||
export const validateRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -65,13 +107,16 @@ export const validateRequest = (req: Request, res: Response, next: NextFunction)
|
||||
}
|
||||
|
||||
// Validate content type for POST/PUT/PATCH requests
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method) && !req.is('application/json')) {
|
||||
return res.status(415).json({
|
||||
success: false,
|
||||
message: 'Unsupported Media Type',
|
||||
error: 'Content-Type must be application/json',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
||||
const contentType = req.headers['content-type'];
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return res.status(415).json({
|
||||
success: false,
|
||||
message: 'Unsupported Media Type',
|
||||
error: 'Content-Type must be application/json',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate request body size
|
||||
@@ -101,23 +146,42 @@ export const validateRequest = (req: Request, res: Response, next: NextFunction)
|
||||
next();
|
||||
};
|
||||
|
||||
// Input sanitization middleware
|
||||
// Enhanced input sanitization middleware
|
||||
export const sanitizeInput = (req: Request, _res: Response, next: NextFunction) => {
|
||||
if (req.body) {
|
||||
// Recursively sanitize object
|
||||
const sanitizeObject = (obj: any): any => {
|
||||
const sanitizeValue = (value: unknown): unknown => {
|
||||
if (typeof value === 'string') {
|
||||
// Sanitize HTML content
|
||||
return sanitizeHtml(value, {
|
||||
allowedTags: [], // Remove all HTML tags
|
||||
allowedAttributes: {}, // Remove all attributes
|
||||
textFilter: (text) => {
|
||||
// Remove potential XSS patterns
|
||||
return text.replace(/javascript:/gi, '')
|
||||
.replace(/data:/gi, '')
|
||||
.replace(/vbscript:/gi, '')
|
||||
.replace(/on\w+=/gi, '')
|
||||
.replace(/\b(alert|confirm|prompt|exec|eval|setTimeout|setInterval)\b/gi, '');
|
||||
}
|
||||
});
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const sanitizeObject = (obj: unknown): unknown => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
return sanitizeValue(obj);
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => sanitizeObject(item));
|
||||
}
|
||||
|
||||
const sanitized: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Remove any potentially dangerous characters from keys
|
||||
const sanitizedKey = key.replace(/[<>]/g, '');
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
// Sanitize keys
|
||||
const sanitizedKey = typeof key === 'string' ? sanitizeValue(key) as string : key;
|
||||
// Recursively sanitize values
|
||||
sanitized[sanitizedKey] = sanitizeObject(value);
|
||||
}
|
||||
|
||||
@@ -131,44 +195,68 @@ export const sanitizeInput = (req: Request, _res: Response, next: NextFunction)
|
||||
};
|
||||
|
||||
// Enhanced error handling middleware
|
||||
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
// Handle specific error types
|
||||
if (err.name === 'ValidationError') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation Error',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
if (err.name === 'UnauthorizedError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
if (err.name === 'ForbiddenError') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Forbidden',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Default error response
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal Server Error',
|
||||
error: process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred',
|
||||
export const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
// Log error with request context
|
||||
console.error('Error:', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Handle specific error types
|
||||
switch (err.name) {
|
||||
case 'ValidationError':
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation Error',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
case 'UnauthorizedError':
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
case 'ForbiddenError':
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Forbidden',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
case 'NotFoundError':
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Not Found',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
case 'ConflictError':
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'Conflict',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
default:
|
||||
// Default error response
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal Server Error',
|
||||
error: process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Export all middleware
|
||||
|
||||
Reference in New Issue
Block a user