diff --git a/package-lock.json b/package-lock.json index c41fe18..b9236a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,10 +42,12 @@ "@tanstack/react-query": "^5.60.5", "@types/bcrypt": "^6.0.0", "@types/busboy": "^1.5.4", + "@types/cookie-parser": "^1.4.10", "@types/form-data": "^2.2.1", "@types/memoizee": "^0.4.12", "@types/multer": "^2.0.0", "@types/pg": "^8.15.4", + "@types/validator": "^13.15.10", "bcrypt": "^6.0.0", "busboy": "^1.6.0", "caniuse-lite": "^1.0.30001727", @@ -53,6 +55,8 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "connect-pg-simple": "^10.0.0", + "cookie-parser": "^1.4.7", + "csrf-csrf": "^4.0.3", "date-fns": "^3.6.0", "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", @@ -88,6 +92,7 @@ "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", + "validator": "^13.15.26", "vaul": "^1.1.2", "wouter": "^3.3.5", "ws": "^8.18.0", @@ -3658,6 +3663,15 @@ "@types/pg": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -3930,6 +3944,12 @@ "@types/send": "*" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4543,6 +4563,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -4596,6 +4638,15 @@ "node": ">= 8" } }, + "node_modules/csrf-csrf": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-4.0.3.tgz", + "integrity": "sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==", + "license": "ISC", + "dependencies": { + "http-errors": "^2.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -9127,6 +9178,15 @@ "node": ">= 0.4.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "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 d2e916c..5ea44cb 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "@tanstack/react-query": "^5.60.5", "@types/bcrypt": "^6.0.0", "@types/busboy": "^1.5.4", + "@types/cookie-parser": "^1.4.10", "@types/form-data": "^2.2.1", "@types/memoizee": "^0.4.12", "@types/multer": "^2.0.0", "@types/pg": "^8.15.4", + "@types/validator": "^13.15.10", "bcrypt": "^6.0.0", "busboy": "^1.6.0", "caniuse-lite": "^1.0.30001727", @@ -55,6 +57,8 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "connect-pg-simple": "^10.0.0", + "cookie-parser": "^1.4.7", + "csrf-csrf": "^4.0.3", "date-fns": "^3.6.0", "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", @@ -90,6 +94,7 @@ "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", + "validator": "^13.15.26", "vaul": "^1.1.2", "wouter": "^3.3.5", "ws": "^8.18.0", diff --git a/server/index.ts b/server/index.ts index 212db5e..5e16e80 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,14 @@ import express, { type Request, Response, NextFunction } from "express"; +import cookieParser from "cookie-parser"; import { registerRoutes } from "./routes.js"; import { setupVite, serveStatic } from "./vite.js"; +import { + setupSecurityHeaders, + setupRateLimiting, + setupInputSanitization, + setupCsrfProtection, + setupCsrfTokenEndpoint +} from "./security.js"; // Forcer la création de la table webhook_bap_config au démarrage de l'application if (process.env.NODE_ENV === 'production') { @@ -16,9 +24,28 @@ console.log('✅ [STARTUP] Weather system initialized'); const app = express(); +// Parse cookies (required for CSRF) +app.use(cookieParser()); + app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: false, limit: '10mb' })); +// Setup security middlewares +console.log('🔐 [STARTUP] Setting up security middlewares...'); +setupSecurityHeaders(app); +setupRateLimiting(app); +setupInputSanitization(app); + +// CSRF Protection (only in production to avoid dev friction) +if (process.env.NODE_ENV === 'production') { + setupCsrfProtection(app); + console.log('✅ [STARTUP] CSRF protection enabled'); +} + +// CSRF token endpoint (always available for frontend to fetch token) +setupCsrfTokenEndpoint(app); +console.log('✅ [STARTUP] Security middlewares configured'); + app.use((req, res, next) => { const start = Date.now(); res.on("finish", () => { diff --git a/server/routes.ts b/server/routes.ts index 977f84f..e2502e5 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -886,8 +886,8 @@ export async function registerRoutes(app: Express): Promise { size: parts.file.buffer.length }); - // Importer form-data dynamiquement avec eval pour ESM - const FormDataModule = await eval('import("form-data")'); + // Import dynamique standard pour ESM (sans eval) + const FormDataModule = await import('form-data'); const FormData = FormDataModule.default; const formData = new FormData(); diff --git a/server/security.ts b/server/security.ts index b112d66..61b61e9 100644 --- a/server/security.ts +++ b/server/security.ts @@ -1,40 +1,139 @@ import { Express, Request, Response, NextFunction } from 'express'; import rateLimit from 'express-rate-limit'; +import validator from 'validator'; +import { randomBytes } from 'crypto'; + +// ============================================================================ +// CSRF Protection (Double Submit Cookie Pattern) +// ============================================================================ + +const CSRF_COOKIE_NAME = 'csrf_token'; +const CSRF_HEADER_NAME = 'x-csrf-token'; + +/** + * Generate a cryptographically secure CSRF token + */ +export function generateCsrfToken(): string { + return randomBytes(32).toString('hex'); +} + +/** + * Setup CSRF protection middleware using Double Submit Cookie pattern + * This pattern works well with SPAs and doesn't require server-side session storage + */ +export function setupCsrfProtection(app: Express) { + // Middleware to set CSRF cookie on every response + app.use((req: Request, res: Response, next: NextFunction) => { + // Only set cookie if not already present + if (!req.cookies?.[CSRF_COOKIE_NAME]) { + const token = generateCsrfToken(); + res.cookie(CSRF_COOKIE_NAME, token, { + httpOnly: false, // Must be readable by JS for double submit + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 24 * 60 * 60 * 1000 // 24 hours + }); + } + next(); + }); + + // Middleware to validate CSRF token on state-changing requests + app.use((req: Request, res: Response, next: NextFunction) => { + const safeMethods = ['GET', 'HEAD', 'OPTIONS']; + + // Skip CSRF check for safe methods + if (safeMethods.includes(req.method)) { + return next(); + } + + // Skip CSRF check for API endpoints that use other auth (e.g., webhook callbacks) + const csrfExemptPaths = [ + '/api/health', + '/api/webhook', // External webhook callbacks + ]; + + if (csrfExemptPaths.some(path => req.path.startsWith(path))) { + return next(); + } + + const cookieToken = req.cookies?.[CSRF_COOKIE_NAME]; + const headerToken = req.headers[CSRF_HEADER_NAME] as string; + + // Validate CSRF token + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + console.warn(`🚨 CSRF validation failed for ${req.method} ${req.path} from IP: ${req.ip}`); + return res.status(403).json({ + error: 'CSRF token validation failed', + message: 'Request rejected due to security validation failure' + }); + } + + next(); + }); +} + +/** + * Get current CSRF token endpoint for frontend + */ +export function setupCsrfTokenEndpoint(app: Express) { + app.get('/api/csrf-token', (req: Request, res: Response) => { + let token = req.cookies?.[CSRF_COOKIE_NAME]; + + if (!token) { + token = generateCsrfToken(); + res.cookie(CSRF_COOKIE_NAME, token, { + httpOnly: false, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 24 * 60 * 60 * 1000 + }); + } + + res.json({ csrfToken: token }); + }); +} + +// ============================================================================ +// Security Headers +// ============================================================================ -// Headers de sécurité export function setupSecurityHeaders(app: Express) { app.use((req: Request, res: Response, next: NextFunction) => { // Protection contre les attaques XSS res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-XSS-Protection', '1; mode=block'); - + // Protection HTTPS if (process.env.NODE_ENV === 'production') { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } - - // Politique de sécurité du contenu - res.setHeader('Content-Security-Policy', + + // Politique de sécurité du contenu (renforcée) + res.setHeader('Content-Security-Policy', "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "connect-src 'self' ws: wss:; " + - "font-src 'self' data:;" + "font-src 'self' data:; " + + "form-action 'self';" // Prevent form submissions to external sites ); - + // Protection contre les attaques de référence res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - + // Protection des données sensibles res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); - + next(); }); } -// Limitation du taux de requêtes +// ============================================================================ +// Rate Limiting +// ============================================================================ + export function setupRateLimiting(app: Express) { // Limiteur général const generalLimiter = rateLimit({ @@ -45,14 +144,12 @@ export function setupRateLimiting(app: Express) { }, standardHeaders: true, legacyHeaders: false, - trustProxy: 1, // Configuration sécurisée pour Docker (1 proxy de confiance) skip: (req) => { - // Skip rate limiting for health checks return req.path === '/api/health'; } }); - // Limiteur pour l'authentification + // Limiteur pour l'authentification (strict) const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // limite les tentatives de connexion @@ -61,20 +158,17 @@ export function setupRateLimiting(app: Express) { }, standardHeaders: true, legacyHeaders: false, - trustProxy: 1, // Configuration sécurisée pour Docker (1 proxy de confiance) }); - // Limiteur pour l'API - adapté pour une utilisation normale + // Limiteur pour l'API const apiLimiter = rateLimit({ windowMs: 1 * 60 * 1000, // 1 minute - max: process.env.NODE_ENV === 'development' ? 500 : 300, // 500 en dev, 300 en prod + max: process.env.NODE_ENV === 'development' ? 500 : 300, message: { error: 'Limite API atteinte, veuillez ralentir vos requêtes.', }, standardHeaders: true, legacyHeaders: false, - trustProxy: 1, // Configuration sécurisée pour Docker (1 proxy de confiance) - // Exclure certaines routes critiques du rate limiting strict skip: (req) => { return req.path === '/api/health' || req.path === '/api/user'; }, @@ -91,52 +185,173 @@ export function setupRateLimiting(app: Express) { app.use('/api/', apiLimiter); } -// Validation et nettoyage des entrées +// ============================================================================ +// Input Sanitization (Using validator.js) +// ============================================================================ + +/** + * Sanitize a single string value using validator.js + * Protects against XSS, SQL injection patterns, and path traversal + */ +export function sanitizeString(input: string): string { + if (typeof input !== 'string') return input; + + let sanitized = input.trim(); + + // Escape HTML entities to prevent XSS + sanitized = validator.escape(sanitized); + + // Remove null bytes (used in some injection attacks) + sanitized = sanitized.replace(/\0/g, ''); + + // Remove path traversal attempts + sanitized = sanitized.replace(/\.\.\//g, '').replace(/\.\.\\/g, ''); + + return sanitized; +} + +/** + * Recursively sanitize all string values in an object + */ export function sanitizeInput(input: any): any { if (typeof input === 'string') { - // Supprimer les caractères dangereux - return input.replace(/[<>]/g, '').trim(); + return sanitizeString(input); } - + + if (Array.isArray(input)) { + return input.map(item => sanitizeInput(item)); + } + if (typeof input === 'object' && input !== null) { const sanitized: any = {}; for (const key in input) { - sanitized[key] = sanitizeInput(input[key]); + // Also sanitize object keys to prevent prototype pollution + const sanitizedKey = sanitizeString(key); + if (sanitizedKey === '__proto__' || sanitizedKey === 'constructor' || sanitizedKey === 'prototype') { + continue; // Skip prototype pollution attempts + } + sanitized[sanitizedKey] = sanitizeInput(input[key]); } return sanitized; } - + return input; } +/** + * Validate and sanitize email + */ +export function sanitizeEmail(email: string): string | null { + if (!email || typeof email !== 'string') return null; + + const normalized = validator.normalizeEmail(email); + if (!normalized || !validator.isEmail(normalized)) { + return null; + } + + return normalized; +} + +/** + * Validate and sanitize URL + */ +export function sanitizeUrl(url: string): string | null { + if (!url || typeof url !== 'string') return null; + + if (!validator.isURL(url, { + protocols: ['http', 'https'], + require_protocol: true, + require_valid_protocol: true + })) { + return null; + } + + return url; +} + +/** + * Check for SQL injection patterns (for logging/monitoring) + */ +export function detectSqlInjection(input: string): boolean { + if (typeof input !== 'string') return false; + + const sqlPatterns = [ + /(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXEC|UNION|OR|AND)\b.*\b(FROM|INTO|TABLE|WHERE|SET)\b)/i, + /(['"]?\s*(OR|AND)\s*['"]?\s*['"]?\s*=\s*['"]?)/i, + /(--|\#|\/\*|\*\/)/, + /(\bEXEC\b|\bEXECUTE\b|\bxp_)/i, + ]; + + return sqlPatterns.some(pattern => pattern.test(input)); +} + // Middleware de nettoyage des requêtes export function setupInputSanitization(app: Express) { app.use((req: Request, res: Response, next: NextFunction) => { + // Log potential SQL injection attempts + const checkAndLog = (data: any, source: string) => { + if (typeof data === 'object' && data !== null) { + for (const key in data) { + const value = data[key]; + if (typeof value === 'string' && detectSqlInjection(value)) { + console.warn(`🚨 Potential SQL injection detected in ${source}:`, { + ip: req.ip, + path: req.path, + key, + value: value.substring(0, 100) // Truncate for logging + }); + } + } + } + }; + if (req.body) { + checkAndLog(req.body, 'body'); req.body = sanitizeInput(req.body); } if (req.query) { + checkAndLog(req.query, 'query'); req.query = sanitizeInput(req.query); } if (req.params) { + checkAndLog(req.params, 'params'); req.params = sanitizeInput(req.params); } next(); }); } -// Middleware de logging sécurisé +// ============================================================================ +// Secure Logging +// ============================================================================ + +const SENSITIVE_KEYS = ['password', 'token', 'secret', 'apikey', 'api_key', 'authorization', 'cookie']; + export function secureLog(message: string, data?: any) { const timestamp = new Date().toISOString(); - const logData = data ? JSON.stringify(data, null, 2) : ''; - - // En production, ne pas logger les données sensibles - if (process.env.NODE_ENV === 'production') { - if (message.includes('password') || message.includes('token')) { - console.log(`[${timestamp}] ${message} - [SENSITIVE DATA HIDDEN]`); - } else { - console.log(`[${timestamp}] ${message}`, logData); + + // Mask sensitive data + const maskSensitive = (obj: any): any => { + if (!obj || typeof obj !== 'object') return obj; + + const masked: any = Array.isArray(obj) ? [] : {}; + for (const key in obj) { + if (SENSITIVE_KEYS.some(s => key.toLowerCase().includes(s))) { + masked[key] = '[REDACTED]'; + } else if (typeof obj[key] === 'object') { + masked[key] = maskSensitive(obj[key]); + } else { + masked[key] = obj[key]; + } } + return masked; + }; + + const logData = data ? JSON.stringify(maskSensitive(data), null, 2) : ''; + + // Check message for sensitive content + if (SENSITIVE_KEYS.some(s => message.toLowerCase().includes(s))) { + console.log(`[${timestamp}] ${message} - [SENSITIVE DATA HIDDEN]`); } else { console.log(`[${timestamp}] ${message}`, logData); }