veza/apps/web/src/utils/sanitize.ts

409 lines
9.5 KiB
TypeScript

/**
* 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<string, string[]>;
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
/<script[^>]*>[\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
/<iframe[^>]*>[\s\S]*?<\/iframe>/gi,
/<object[^>]*>[\s\S]*?<\/object>/gi,
/<embed[^>]*>/gi,
/<applet[^>]*>[\s\S]*?<\/applet>/gi,
/<form[^>]*>[\s\S]*?<\/form>/gi,
/<input[^>]*>/gi,
/<textarea[^>]*>[\s\S]*?<\/textarea>/gi,
/<select[^>]*>[\s\S]*?<\/select>/gi,
/<button[^>]*>[\s\S]*?<\/button>/gi,
// Meta tags dangereux
/<meta[^>]*>/gi,
/<link[^>]*>/gi,
/<style[^>]*>[\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, string[]>,
): 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<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
};
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,
};
}