diff --git a/package-lock.json b/package-lock.json index 1dae23b..6812798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,19 @@ "@prisma/client": "^6.11.1", "chromadb": "^1.8.1", "cors": "^2.8.5", + "crypto-js": "^4.2.0", "dotenv": "^17.1.0", "express": "^5.1.0", + "express-rate-limit": "^7.5.1", + "express-validator": "^7.2.1", + "helmet": "^8.1.0", "openai": "^4.68.4", "prisma": "^6.11.1", "zod": "^3.25.76" }, "devDependencies": { "@types/cors": "^2.8.19", + "@types/crypto-js": "^4.2.2", "@types/express": "^5.0.3", "@types/node": "^24.0.12", "nodemon": "^3.1.10", @@ -203,6 +208,13 @@ "@types/node": "*" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", @@ -686,6 +698,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -889,6 +907,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-validator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", + "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1128,6 +1174,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -1276,6 +1331,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -2026,6 +2087,15 @@ "dev": true, "license": "MIT" }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index fc82c23..fa00a68 100644 --- a/package.json +++ b/package.json @@ -26,14 +26,19 @@ "@prisma/client": "^6.11.1", "chromadb": "^1.8.1", "cors": "^2.8.5", + "crypto-js": "^4.2.0", "dotenv": "^17.1.0", "express": "^5.1.0", + "express-rate-limit": "^7.5.1", + "express-validator": "^7.2.1", + "helmet": "^8.1.0", "openai": "^4.68.4", "prisma": "^6.11.1", "zod": "^3.25.76" }, "devDependencies": { "@types/cors": "^2.8.19", + "@types/crypto-js": "^4.2.2", "@types/express": "^5.0.3", "@types/node": "^24.0.12", "nodemon": "^3.1.10", diff --git a/src/config/matching.config.ts b/src/config/matching.config.ts index 0aa5716..de39e3a 100644 --- a/src/config/matching.config.ts +++ b/src/config/matching.config.ts @@ -1,5 +1,6 @@ import { ChromaClient } from 'chromadb'; import OpenAI from 'openai'; +import { EnvironmentEncryption } from '../utils/encryption.utils'; export interface MatchingConfig { chromaHost: string; @@ -10,7 +11,9 @@ export interface MatchingConfig { export const getMatchingConfig = (): MatchingConfig => { const chromaHost = process.env.CHROMA_HOST || 'localhost'; const chromaPort = parseInt(process.env.CHROMA_PORT || '8000'); - const openaiApiKey = process.env.OPENAI_API_KEY; + + // Use secure environment variable handling + const openaiApiKey = EnvironmentEncryption.getSecureEnvVar('OPENAI_API_KEY', false); if (!openaiApiKey) { throw new Error('OPENAI_API_KEY environment variable is required'); diff --git a/src/index.ts b/src/index.ts index 2d04b03..f60a4d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,14 +7,37 @@ import jobRoutes from './routes/job.routes'; import applicationRoutes from './routes/application.routes'; import matchingRoutes from './routes/matching.routes'; +// Security imports +import { SecurityMiddleware } from './middlewares/security.middleware'; +import { RateLimitConfig } from './utils/rate-limit.utils'; + dotenv.config(); const app = express(); const PORT = process.env.PORT || 3000; -app.use(cors()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +// Security middleware +app.use(SecurityMiddleware.helmet()); +app.use(SecurityMiddleware.securityHeaders()); +app.use(SecurityMiddleware.securityLogger()); +app.use(SecurityMiddleware.requestSizeLimiter()); + +// Rate limiting +app.use(RateLimitConfig.general()); + +// CORS configuration +app.use(cors({ + origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'], + credentials: true, + optionsSuccessStatus: 200 +})); + +// Body parsing with security +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Request sanitization +app.use(SecurityMiddleware.sanitizeRequest()); app.get('/', (req, res) => { res.json({ @@ -32,11 +55,11 @@ app.get('/health', (req, res) => { }); }); -app.use('/api/candidates', candidateRoutes); -app.use('/api/jobs', jobRoutes); -app.use('/api/applications', applicationRoutes); -app.use('/api/matching', matchingRoutes); -app.use('/api/matching', matchingRoutes); +// API routes with specific rate limits +app.use('/api/candidates', RateLimitConfig.dataModification(), candidateRoutes); +app.use('/api/jobs', RateLimitConfig.dataModification(), jobRoutes); +app.use('/api/applications', RateLimitConfig.dataModification(), applicationRoutes); +app.use('/api/matching', RateLimitConfig.aiMatching(), matchingRoutes); app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); diff --git a/src/middlewares/enhanced-validation.middleware.ts b/src/middlewares/enhanced-validation.middleware.ts new file mode 100644 index 0000000..68457d4 --- /dev/null +++ b/src/middlewares/enhanced-validation.middleware.ts @@ -0,0 +1,140 @@ +import { Request, Response, NextFunction } from 'express'; +import { body, param } from 'express-validator'; +import { SecurityMiddleware } from './security.middleware'; +import { InputSanitizer } from '../utils/sanitization.utils'; + +/** + * Enhanced validation middleware for specific endpoints + * Provides additional security validation for sensitive operations + */ + +export class EnhancedValidationMiddleware { + /** + * Enhanced candidate creation validation + */ + static validateCandidateCreation() { + return [ + InputSanitizer.sanitizeString('firstName', 2, 50), + InputSanitizer.sanitizeString('lastName', 2, 50), + InputSanitizer.sanitizeEmail('email'), + InputSanitizer.sanitizeString('phone', 10, 20), + InputSanitizer.sanitizeArray('skills', 20), + InputSanitizer.sanitizeInteger('experience', 0, 50), + InputSanitizer.sanitizeString('location', 2, 100), + InputSanitizer.sanitizeString('availability', 2, 50), + body('skills.*').isString().trim().isLength({ min: 1, max: 50 }), + SecurityMiddleware.handleValidationErrors() + ]; + } + + /** + * Enhanced job creation validation + */ + static validateJobCreation() { + return [ + InputSanitizer.sanitizeString('title', 3, 100), + InputSanitizer.sanitizeString('company', 2, 100), + InputSanitizer.sanitizeString('description', 10, 2000), + InputSanitizer.sanitizeArray('required_skills', 20), + InputSanitizer.sanitizeArray('preferred_skills', 20), + InputSanitizer.sanitizeString('location', 2, 100), + InputSanitizer.sanitizeBoolean('remote_ok'), + body('required_skills.*').isString().trim().isLength({ min: 1, max: 50 }), + body('preferred_skills.*').optional().isString().trim().isLength({ min: 1, max: 50 }), + body('experience_level').isIn(['entry', 'mid', 'senior', 'lead']), + body('employment_type').isIn(['full-time', 'part-time', 'contract', 'internship']), + SecurityMiddleware.handleValidationErrors() + ]; + } + + /** + * Enhanced application creation validation + */ + static validateApplicationCreation() { + return [ + InputSanitizer.sanitizeUUID('candidate_id'), + InputSanitizer.sanitizeUUID('job_position_id'), + InputSanitizer.sanitizeString('cover_letter', 10, 1000), + SecurityMiddleware.handleValidationErrors() + ]; + } + + /** + * Enhanced matching request validation + */ + static validateMatchingRequest() { + return [ + param('id').isUUID().withMessage('Invalid ID format'), + InputSanitizer.sanitizeQueryParam('limit'), + InputSanitizer.sanitizeQueryParam('location'), + InputSanitizer.sanitizeQueryParam('experience_level'), + SecurityMiddleware.handleValidationErrors() + ]; + } + + /** + * File upload validation (for future use) + */ + static validateFileUpload() { + return (req: Request & { file?: any }, res: Response, next: NextFunction) => { + if (req.file) { + const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png']; + const maxSize = 5 * 1024 * 1024; + + if (!allowedTypes.includes(req.file.mimetype)) { + return res.status(400).json({ + success: false, + message: 'Invalid file type. Only PDF, JPEG, and PNG files are allowed.' + }); + } + + if (req.file.size > maxSize) { + return res.status(400).json({ + success: false, + message: 'File size exceeds 5MB limit.' + }); + } + } + + next(); + }; + } + + /** + * SQL injection prevention (additional layer) + */ + static preventSQLInjection() { + return (req: Request, res: Response, next: NextFunction) => { + const sqlInjectionPatterns = [ + /(\b(SELECT|INSERT|UPDATE|DELETE|DROP|UNION|ALTER|CREATE)\b)/i, + /('|(\\)|;|--|\/\*|\*\/)/, + /(EXEC|EXECUTE|SP_|XP_)/i + ]; + + const checkForSQLInjection = (obj: any): boolean => { + if (typeof obj === 'string') { + return sqlInjectionPatterns.some(pattern => pattern.test(obj)); + } + + if (typeof obj === 'object' && obj !== null) { + for (const value of Object.values(obj)) { + if (checkForSQLInjection(value)) { + return true; + } + } + } + + return false; + }; + + if (checkForSQLInjection(req.body) || checkForSQLInjection(req.query) || checkForSQLInjection(req.params)) { + return res.status(400).json({ + success: false, + message: 'Invalid input detected' + }); + } + + next(); + }; + } +} diff --git a/src/middlewares/security.middleware.ts b/src/middlewares/security.middleware.ts new file mode 100644 index 0000000..f1f3332 --- /dev/null +++ b/src/middlewares/security.middleware.ts @@ -0,0 +1,194 @@ +import helmet from 'helmet'; +import { Request, Response, NextFunction } from 'express'; +import { validationResult } from 'express-validator'; +import { InputSanitizer } from '../utils/sanitization.utils'; + +/** + * Security middleware collection + * Provides comprehensive security middleware for the application + */ + +export class SecurityMiddleware { + /** + * Helmet configuration for security headers + */ + static helmet() { + return helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + mediaSrc: ["'self'"], + frameSrc: ["'none'"], + }, + }, + crossOriginEmbedderPolicy: false, + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true + } + }); + } + + /** + * Input validation error handler + */ + static handleValidationErrors() { + return (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Invalid input data', + errors: errors.array().map(error => ({ + field: error.type === 'field' ? (error as any).path : 'unknown', + message: error.msg + })) + }); + } + + next(); + }; + } + + /** + * Request size limiter + */ + static requestSizeLimiter() { + return (req: Request, res: Response, next: NextFunction) => { + const contentLength = req.headers['content-length']; + const maxSize = 10 * 1024 * 1024; // 10MB limit + + if (contentLength && parseInt(contentLength) > maxSize) { + return res.status(413).json({ + success: false, + message: 'Request entity too large', + maxSize: '10MB' + }); + } + + next(); + }; + } + + /** + * Security headers middleware + */ + static securityHeaders() { + return (req: Request, res: Response, next: NextFunction) => { + // Remove server identification + res.removeHeader('X-Powered-By'); + + // Add 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('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + + next(); + }; + } + + /** + * Request sanitization middleware + */ + static sanitizeRequest() { + return (req: Request, res: Response, next: NextFunction) => { + // Sanitize request body + if (req.body && typeof req.body === 'object') { + try { + req.body = this.sanitizeObject(req.body); + } catch (error) { + // Silent fail for body sanitization + } + } + + // Note: Query parameters are read-only in newer Express versions + // They should be sanitized using express-validator in routes instead + + next(); + }; + } + + /** + * Recursively sanitize object properties + */ + private static sanitizeObject(obj: any): any { + if (typeof obj !== 'object' || obj === null) { + return typeof obj === 'string' ? InputSanitizer.removeDangerousChars(obj) : obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => this.sanitizeObject(item)); + } + + const sanitized: any = {}; + for (const [key, value] of Object.entries(obj)) { + const sanitizedKey = InputSanitizer.removeDangerousChars(key); + sanitized[sanitizedKey] = this.sanitizeObject(value); + } + + return sanitized; + } + + /** + * API key validation middleware (if needed for future authentication) + */ + static validateApiKey() { + return (req: Request, res: Response, next: NextFunction) => { + const apiKey = req.headers['x-api-key'] as string; + const validApiKey = process.env.API_KEY; + + // Skip validation if no API key is configured + if (!validApiKey) { + return next(); + } + + if (!apiKey || apiKey !== validApiKey) { + return res.status(401).json({ + success: false, + message: 'Invalid or missing API key' + }); + } + + next(); + }; + } + + /** + * Request logging for security monitoring + */ + static securityLogger() { + return (req: Request, res: Response, next: NextFunction) => { + const startTime = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - startTime; + const logData = { + timestamp: new Date().toISOString(), + method: req.method, + url: req.url, + status: res.statusCode, + duration: `${duration}ms`, + userAgent: req.headers['user-agent'], + ip: req.ip || req.socket.remoteAddress + }; + + // Log security events (failed authentications, rate limits, etc.) + if (res.statusCode === 401 || res.statusCode === 403 || res.statusCode === 429) { + console.warn('Security Event:', JSON.stringify(logData)); + } + }); + + next(); + }; + } +} diff --git a/src/utils/encryption.utils.ts b/src/utils/encryption.utils.ts new file mode 100644 index 0000000..2f98ad0 --- /dev/null +++ b/src/utils/encryption.utils.ts @@ -0,0 +1,70 @@ +import CryptoJS from 'crypto-js'; + +/** + * Environment variable encryption utilities + * Provides secure handling of sensitive environment variables + */ + +export class EnvironmentEncryption { + private static readonly ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production'; + + /** + * Encrypt sensitive data + */ + static encrypt(text: string): string { + if (!text) return ''; + + try { + return CryptoJS.AES.encrypt(text, this.ENCRYPTION_KEY).toString(); + } catch (error) { + throw new Error('Encryption failed'); + } + } + + /** + * Decrypt sensitive data + */ + static decrypt(encryptedText: string): string { + if (!encryptedText) return ''; + + try { + const bytes = CryptoJS.AES.decrypt(encryptedText, this.ENCRYPTION_KEY); + return bytes.toString(CryptoJS.enc.Utf8); + } catch (error) { + throw new Error('Decryption failed'); + } + } + + /** + * Securely get environment variable with optional decryption + */ + static getSecureEnvVar(key: string, encrypted: boolean = false): string | undefined { + const value = process.env[key]; + if (!value) return undefined; + + if (encrypted) { + try { + return this.decrypt(value); + } catch (error) { + return undefined; + } + } + + return value; + } + + /** + * Mask sensitive data for logging + */ + static maskSensitiveData(data: string, visibleChars: number = 4): string { + if (!data || data.length <= visibleChars) { + return '*'.repeat(8); + } + + const maskedLength = data.length - visibleChars; + const visiblePart = data.slice(0, visibleChars); + const maskedPart = '*'.repeat(Math.min(maskedLength, 20)); + + return `${visiblePart}${maskedPart}`; + } +} diff --git a/src/utils/rate-limit.utils.ts b/src/utils/rate-limit.utils.ts new file mode 100644 index 0000000..c3ae170 --- /dev/null +++ b/src/utils/rate-limit.utils.ts @@ -0,0 +1,125 @@ +import rateLimit, { RateLimitRequestHandler } from 'express-rate-limit'; +import { Request as ExpressRequest, Response } from 'express'; + +/** + * Rate limiting configuration + * Provides different rate limiting strategies for various endpoints + */ + +export class RateLimitConfig { + /** + * General API rate limiting + */ + static general() { + return rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // Limit each IP to 100 requests per windowMs + message: { + error: 'Too many requests', + message: 'Request limit exceeded. Please try again later.', + retryAfter: '15 minutes' + }, + headers: true, + skip: (req) => { + // Skip rate limiting for health checks + return req.url === '/health' || req.url === '/'; + } + }); + } + + /** + * Authentication endpoints rate limiting (stricter) + */ + static authentication() { + return rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // Limit each IP to 5 authentication attempts per windowMs + message: { + error: 'Too many authentication attempts', + message: 'Authentication limit exceeded. Please try again later.', + retryAfter: '15 minutes' + }, + headers: true + }); + } + + /** + * AI matching endpoints rate limiting (moderate) + */ + static aiMatching() { + return rateLimit({ + windowMs: 5 * 60 * 1000, // 5 minutes + max: 10, // Limit each IP to 10 AI requests per 5 minutes + message: { + error: 'AI matching limit exceeded', + message: 'Too many AI matching requests. Please wait before trying again.', + retryAfter: '5 minutes' + }, + headers: true + }); + } + + /** + * Data modification endpoints rate limiting + */ + static dataModification() { + return rateLimit({ + windowMs: 10 * 60 * 1000, // 10 minutes + max: 30, // Limit each IP to 30 data modification requests per 10 minutes + message: { + error: 'Data modification limit exceeded', + message: 'Too many data modification requests. Please wait before trying again.', + retryAfter: '10 minutes' + }, + headers: true + }); + } + + /** + * Read-only endpoints rate limiting (more permissive) + */ + static readOnly() { + return rateLimit({ + windowMs: 5 * 60 * 1000, // 5 minutes + max: 200, // Limit each IP to 200 read requests per 5 minutes + message: { + error: 'Read limit exceeded', + message: 'Too many read requests. Please wait before trying again.', + retryAfter: '5 minutes' + }, + headers: true + }); + } + + /** + * Custom rate limiter with configurable parameters + */ + static custom(windowMs: number, max: number, message: string) { + return rateLimit({ + windowMs, + max, + message: { + error: 'Rate limit exceeded', + message, + retryAfter: `${Math.ceil(windowMs / 60000)} minutes` + }, + headers: true + }); + } + + /** + * Skip rate limiting for specific conditions + */ + static skipConditions = { + healthChecks: (req: any) => { + return req.url === '/health' || req.url === '/' || req.url === '/api/health'; + }, + + internalRequests: (req: any) => { + // Skip for requests from internal services (if applicable) + const internalIPs = ['127.0.0.1', '::1']; + const clientIP = req.ip || req.socket?.remoteAddress; + return internalIPs.includes(clientIP); + } + }; +} diff --git a/src/utils/sanitization.utils.ts b/src/utils/sanitization.utils.ts new file mode 100644 index 0000000..e80849d --- /dev/null +++ b/src/utils/sanitization.utils.ts @@ -0,0 +1,136 @@ +import { body, param, query, ValidationChain } from 'express-validator'; + +/** + * Input sanitization utilities + * Provides comprehensive input validation and sanitization + */ + +export class InputSanitizer { + /** + * Common text sanitization + */ + static sanitizeText(): ValidationChain { + return body('*') + .trim() + .escape() + .blacklist('<>"\'/\\&') + .isLength({ max: 1000 }) + .withMessage('Text length exceeds maximum allowed'); + } + + /** + * Email sanitization + */ + static sanitizeEmail(field: string = 'email'): ValidationChain { + return body(field) + .isEmail() + .normalizeEmail() + .isLength({ max: 254 }) + .withMessage('Invalid email format'); + } + + /** + * UUID parameter sanitization + */ + static sanitizeUUID(field: string = 'id'): ValidationChain { + return param(field) + .isUUID() + .withMessage('Invalid ID format'); + } + + /** + * Integer sanitization + */ + static sanitizeInteger(field: string, min: number = 0, max: number = Number.MAX_SAFE_INTEGER): ValidationChain { + return body(field) + .isInt({ min, max }) + .toInt() + .withMessage(`${field} must be an integer between ${min} and ${max}`); + } + + /** + * String length sanitization + */ + static sanitizeString(field: string, minLength: number = 1, maxLength: number = 255): ValidationChain { + return body(field) + .isString() + .trim() + .escape() + .isLength({ min: minLength, max: maxLength }) + .withMessage(`${field} must be between ${minLength} and ${maxLength} characters`); + } + + /** + * Array sanitization + */ + static sanitizeArray(field: string, maxItems: number = 50): ValidationChain { + return body(field) + .isArray({ max: maxItems }) + .withMessage(`${field} must be an array with maximum ${maxItems} items`); + } + + /** + * Boolean sanitization + */ + static sanitizeBoolean(field: string): ValidationChain { + return body(field) + .isBoolean() + .toBoolean() + .withMessage(`${field} must be a boolean value`); + } + + /** + * URL sanitization + */ + static sanitizeURL(field: string): ValidationChain { + return body(field) + .isURL() + .isLength({ max: 2048 }) + .withMessage('Invalid URL format'); + } + + /** + * Query parameter sanitization + */ + static sanitizeQueryParam(field: string, maxLength: number = 100): ValidationChain { + return query(field) + .optional() + .trim() + .escape() + .isLength({ max: maxLength }) + .withMessage(`Query parameter ${field} exceeds maximum length`); + } + + /** + * Remove potentially dangerous characters + */ + static removeDangerousChars(input: string): string { + if (!input || typeof input !== 'string') return ''; + + // Remove HTML tags, SQL injection patterns, and script tags + return input + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/<[^>]*>/g, '') + .replace(/[\x00-\x1f\x7f-\x9f]/g, '') + .replace(/['"`;\\]/g, '') + .trim(); + } + + /** + * Validate and sanitize pagination parameters + */ + static sanitizePagination() { + return [ + query('page') + .optional() + .isInt({ min: 1, max: 1000 }) + .toInt() + .withMessage('Page must be between 1 and 1000'), + query('limit') + .optional() + .isInt({ min: 1, max: 100 }) + .toInt() + .withMessage('Limit must be between 1 and 100') + ]; + } +}