/** * XSS Protection utilities * Sanitise et valide le contenu utilisateur pour prévenir les attaques XSS */ import DOMPurify from 'dompurify'; // Types pour la configuration de sanitisation export interface SanitizeOptions { allowedTags?: string[]; allowedAttributes?: Record; allowedSchemes?: string[]; stripUnknownTags?: boolean; stripEmptyTags?: boolean; } // Configuration par défaut pour la sanitisation const DEFAULT_OPTIONS: SanitizeOptions = { allowedTags: [ 'p', 'br', 'strong', 'em', 'u', 'i', 'b', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'a', 'img', ], allowedAttributes: { a: ['href', 'title', 'target'], img: ['src', 'alt', 'title', 'width', 'height'], span: ['class'], div: ['class'], p: ['class'], pre: ['class'], code: ['class'], }, allowedSchemes: ['http', 'https', 'mailto'], stripUnknownTags: true, stripEmptyTags: true, }; /** * Patterns dangereux à détecter et supprimer */ const DANGEROUS_PATTERNS = [ // Scripts et exécution de code /]*>[\s\S]*?<\/script>/gi, /javascript:/gi, /vbscript:/gi, /data:text\/html/gi, /data:application\/javascript/gi, // Event handlers inline /on\w+\s*=\s*["'][^"']*["']/gi, /on\w+\s*=\s*[^>\s]+/gi, // Expressions CSS dangereuses /expression\s*\(/gi, /url\s*\(\s*javascript:/gi, // Tags dangereux /]*>[\s\S]*?<\/iframe>/gi, /]*>[\s\S]*?<\/object>/gi, /]*>/gi, /]*>[\s\S]*?<\/applet>/gi, /]*>[\s\S]*?<\/form>/gi, /]*>/gi, /]*>[\s\S]*?<\/textarea>/gi, /]*>[\s\S]*?<\/select>/gi, /]*>[\s\S]*?<\/button>/gi, // Meta tags dangereux /]*>/gi, /]*>/gi, /]*>[\s\S]*?<\/style>/gi, ]; /** * Patterns pour les URLs suspectes */ const SUSPICIOUS_URL_PATTERNS = [ /javascript:/i, /vbscript:/i, /data:/i, /file:/i, /ftp:/i, /gopher:/i, /jar:/i, /ldap:/i, /ldaps:/i, /magnet:/i, /news:/i, /nntp:/i, /sftp:/i, /smb:/i, /ssh:/i, /telnet:/i, /tftp:/i, /view-source:/i, ]; /** * Sanitise le contenu HTML pour prévenir les attaques XSS */ export function sanitizeHTML( content: string, options: SanitizeOptions = {}, ): string { const config = { ...DEFAULT_OPTIONS, ...options }; let sanitized = content; // Supprimer les patterns dangereux DANGEROUS_PATTERNS.forEach((pattern) => { sanitized = sanitized.replace(pattern, ''); }); // Supprimer les tags non autorisés if (config.stripUnknownTags) { sanitized = stripUnknownTags(sanitized, config.allowedTags!); } // Nettoyer les attributs sanitized = sanitizeAttributes(sanitized, config.allowedAttributes!); // Valider les URLs sanitized = validateURLs(sanitized, config.allowedSchemes!); // Supprimer les tags vides if (config.stripEmptyTags) { sanitized = stripEmptyTags(sanitized); } // Échapper les caractères HTML restants sanitized = escapeHTML(sanitized); return sanitized; } /** * Supprime les tags non autorisés */ function stripUnknownTags(content: string, allowedTags: string[]): string { const tagPattern = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g; return content.replace(tagPattern, (_match, tagName) => { if (allowedTags.includes(tagName.toLowerCase())) { return _match; } return ''; }); } /** * Nettoie les attributs des tags */ function sanitizeAttributes( content: string, allowedAttributes: Record, ): string { const tagPattern = /<([a-zA-Z][a-zA-Z0-9]*)([^>]*)>/g; return content.replace(tagPattern, (_match, tagName, attributes) => { const allowedAttrs = allowedAttributes[tagName.toLowerCase()]; if (!allowedAttrs) { return `<${tagName}>`; } const attrPattern = /(\w+)\s*=\s*["']([^"']*)["']/g; const cleanAttributes = attributes.replace( attrPattern, (_attrMatch: string, attrName: string, attrValue: string) => { if (allowedAttrs.includes(attrName.toLowerCase())) { // Valider les URLs dans les attributs href et src if ( attrName.toLowerCase() === 'href' || attrName.toLowerCase() === 'src' ) { if (isValidURL(attrValue)) { return `${attrName}="${attrValue}"`; } return ''; } return _attrMatch; } return ''; }, ); return `<${tagName}${cleanAttributes}>`; }); } /** * Valide les URLs dans le contenu */ function validateURLs(content: string, allowedSchemes: string[]): string { const urlPattern = /(https?:\/\/[^\s<>"']+)/g; return content.replace(urlPattern, (url) => { try { const urlObj = new URL(url); if (allowedSchemes.includes(urlObj.protocol.slice(0, -1))) { return url; } } catch { // URL invalide } return ''; }); } /** * Vérifie si une URL est valide et sûre */ function isValidURL(url: string): boolean { try { const urlObj = new URL(url); // Vérifier le protocole if (!['http:', 'https:', 'mailto:'].includes(urlObj.protocol)) { return false; } // Vérifier les patterns suspects return !SUSPICIOUS_URL_PATTERNS.some((pattern) => pattern.test(url)); } catch { return false; } } /** * Supprime les tags vides */ function stripEmptyTags(content: string): string { return content.replace(/<([a-zA-Z][a-zA-Z0-9]*)[^>]*>\s*<\/\1>/g, ''); } /** * Échappe les caractères HTML spéciaux */ function escapeHTML(content: string): string { const escapeMap: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return content.replace(/[&<>"'/]/g, (char) => escapeMap[char]); } /** * Sanitise spécifiquement les messages de chat */ /** * Sanitise les messages de chat avec DOMPurify pour une protection XSS robuste * Utilise DOMPurify en priorité, avec fallback sur sanitizeHTML si DOMPurify n'est pas disponible */ export function sanitizeChatMessage(message: string): string { // Utiliser DOMPurify pour une sanitisation robuste et éprouvée if (typeof window !== 'undefined' && DOMPurify.isSupported) { return DOMPurify.sanitize(message, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'i', 'b', 'span', 'a'], ALLOWED_ATTR: ['class', 'href', 'title', 'target'], ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, KEEP_CONTENT: true, RETURN_DOM: false, RETURN_DOM_FRAGMENT: false, RETURN_TRUSTED_TYPE: false, }); } // Fallback sur la sanitisation manuelle si DOMPurify n'est pas disponible (SSR) const chatOptions: SanitizeOptions = { allowedTags: ['p', 'br', 'strong', 'em', 'u', 'i', 'b', 'span'], allowedAttributes: { span: ['class'], }, allowedSchemes: ['http', 'https'], stripUnknownTags: true, stripEmptyTags: true, }; return sanitizeHTML(message, chatOptions); } /** * Sanitise les noms d'utilisateur et autres champs texte */ export function sanitizeTextInput(input: string): string { // Pour les champs texte simples, on échappe tout le HTML return escapeHTML(input.trim()); } /** * Valide et nettoie les URLs utilisateur */ export function sanitizeURL(url: string): string | null { try { const urlObj = new URL(url); // Seuls HTTP et HTTPS sont autorisés if (!['http:', 'https:'].includes(urlObj.protocol)) { return null; } // Vérifier les patterns suspects if (SUSPICIOUS_URL_PATTERNS.some((pattern) => pattern.test(url))) { return null; } return urlObj.toString(); } catch { return null; } } /** * Valide les emails */ export function sanitizeEmail(email: string): string | null { const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const sanitized = email.trim().toLowerCase(); if (emailPattern.test(sanitized)) { return sanitized; } return null; } /** * Valide les mots de passe selon les critères de sécurité */ export function validatePassword(password: string): { isValid: boolean; errors: string[]; } { const errors: string[] = []; if (password.length < 12) { errors.push('Le mot de passe doit contenir au moins 12 caractères'); } if (!/[A-Z]/.test(password)) { errors.push('Le mot de passe doit contenir au moins une majuscule'); } if (!/[a-z]/.test(password)) { errors.push('Le mot de passe doit contenir au moins une minuscule'); } if (!/[0-9]/.test(password)) { errors.push('Le mot de passe doit contenir au moins un chiffre'); } if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) { errors.push('Le mot de passe doit contenir au moins un caractère spécial'); } // Vérifier les patterns communs faibles const weakPatterns = [ /(.)\1{3,}/, // Répétition de caractères /123456/, // Séquence numérique /password/i, // Mot "password" /qwerty/i, // Mot "qwerty" ]; if (weakPatterns.some((pattern) => pattern.test(password))) { errors.push('Le mot de passe contient des patterns trop communs'); } return { isValid: errors.length === 0, errors, }; } /** * Hook React pour utiliser la sanitisation */ export function useSanitization() { return { sanitizeHTML, sanitizeChatMessage, sanitizeTextInput, sanitizeURL, sanitizeEmail, validatePassword, }; }