/** * XSS Protection utilities * Sanitise et valide le contenu utilisateur pour prévenir les attaques XSS */ // 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, attrName, attrValue) => { 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 */ export function sanitizeChatMessage(message: string): string { 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 } }