feat(mail) : helper sanitizeMailHtml — DOMPurify + placeholder images distantes
This commit is contained in:
160
frontend/utils/sanitizeMailHtml.ts
Normal file
160
frontend/utils/sanitizeMailHtml.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import DOMPurify, { type Config as DOMPurifyConfig } from 'dompurify'
|
||||
|
||||
/**
|
||||
* Options de sanitization du corps HTML d'un mail.
|
||||
*/
|
||||
export type SanitizeMailHtmlOptions = {
|
||||
/**
|
||||
* Si true, les images distantes (http/https) sont affichées directement.
|
||||
* Par défaut false — les images distantes sont remplacées par un placeholder
|
||||
* cliquable pour éviter le tracking par pixel.
|
||||
*/
|
||||
allowImages?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration DOMPurify bloquante pour les corps de mail.
|
||||
* - Bloque les balises dangereuses : script, iframe, object, embed, style, link, meta, form, input
|
||||
* - Bloque les attributs événements (on*) et les URI javascript:
|
||||
* - Autorise les URI data: uniquement pour les images (PNG/JPEG/GIF/WEBP) — images inline CID
|
||||
*/
|
||||
const DOMPURIFY_CONFIG: DOMPurifyConfig = {
|
||||
FORBID_TAGS: [
|
||||
'script',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'style',
|
||||
'link',
|
||||
'meta',
|
||||
'form',
|
||||
'input',
|
||||
'button',
|
||||
'textarea',
|
||||
'select',
|
||||
'base',
|
||||
'applet',
|
||||
],
|
||||
FORBID_ATTR: [
|
||||
'onerror',
|
||||
'onload',
|
||||
'onclick',
|
||||
'onmouseover',
|
||||
'onmouseout',
|
||||
'onmouseenter',
|
||||
'onmouseleave',
|
||||
'onfocus',
|
||||
'onblur',
|
||||
'onchange',
|
||||
'onsubmit',
|
||||
'onreset',
|
||||
'onkeydown',
|
||||
'onkeyup',
|
||||
'onkeypress',
|
||||
'ondblclick',
|
||||
'oncontextmenu',
|
||||
'onwheel',
|
||||
'ondrag',
|
||||
'ondrop',
|
||||
'oncopy',
|
||||
'oncut',
|
||||
'onpaste',
|
||||
'action',
|
||||
'formaction',
|
||||
'xlink:href',
|
||||
],
|
||||
ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel|cid|data:image\/(?:png|jpeg|gif|webp)(?:;base64,)?)(?::|$)/i,
|
||||
FORCE_BODY: true,
|
||||
WHOLE_DOCUMENT: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplace les balises <img> avec src http(s):// par un bouton placeholder.
|
||||
* Le src original est stocké en data-mail-image-src pour permettre l'affichage
|
||||
* à la demande de l'utilisateur (Phase 5 — MailMessageViewer).
|
||||
*/
|
||||
function replaceRemoteImages(html: string): string {
|
||||
// Utiliser un DOMParser côté client uniquement (SSR-safe : le guard process.client
|
||||
// est géré par l'appelant dans un composant Vue — ce helper ne tourne que client-side)
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const images = doc.querySelectorAll('img')
|
||||
|
||||
images.forEach((img) => {
|
||||
const src = img.getAttribute('src') ?? ''
|
||||
const isRemote = /^https?:\/\//i.test(src)
|
||||
if (!isRemote) return
|
||||
|
||||
// Remplacer par un span cliquable (pas de <button> — DOMPurify le forbid)
|
||||
const placeholder = doc.createElement('span')
|
||||
placeholder.setAttribute('data-mail-image-src', src)
|
||||
placeholder.setAttribute('data-mail-image-placeholder', 'true')
|
||||
placeholder.setAttribute('title', src)
|
||||
placeholder.style.cssText = [
|
||||
'display: inline-flex',
|
||||
'align-items: center',
|
||||
'gap: 4px',
|
||||
'padding: 2px 6px',
|
||||
'border: 1px solid #d1d5db',
|
||||
'border-radius: 4px',
|
||||
'background: #f9fafb',
|
||||
'color: #6b7280',
|
||||
'font-size: 12px',
|
||||
'cursor: pointer',
|
||||
'user-select: none',
|
||||
].join(';')
|
||||
placeholder.textContent = '[Image distante — cliquer pour afficher]'
|
||||
|
||||
img.replaceWith(placeholder)
|
||||
})
|
||||
|
||||
return doc.body.innerHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize le HTML brut d'un corps de mail.
|
||||
*
|
||||
* - Bloque tous les vecteurs XSS connus (scripts, événements inline, iframes…)
|
||||
* - Par défaut, remplace les images distantes par un placeholder anti-tracking
|
||||
* - Utiliser allowImages: true uniquement si l'utilisateur a explicitement cliqué
|
||||
* "Afficher les images" dans le lecteur de mail
|
||||
*
|
||||
* IMPORTANT : Cette fonction requiert un environnement navigateur (DOMParser, DOMPurify).
|
||||
* Ne pas appeler côté SSR — toujours dans un composant Vue avec `onMounted` ou dans
|
||||
* un computed côté client uniquement (`import.meta.client`).
|
||||
*
|
||||
* @param rawHtml - HTML brut tel que reçu de l'API backend
|
||||
* @param options - Options de sanitization
|
||||
* @returns HTML sanitizé, sûr pour injection via v-html
|
||||
*/
|
||||
export function sanitizeMailHtml(
|
||||
rawHtml: string,
|
||||
options: SanitizeMailHtmlOptions = {},
|
||||
): string {
|
||||
if (!rawHtml || rawHtml.trim() === '') return ''
|
||||
|
||||
// Étape 1 : DOMPurify — supprime tous les vecteurs dangereux
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, DOMPURIFY_CONFIG) as string
|
||||
|
||||
// Étape 2 : Remplacement images distantes (anti-tracking)
|
||||
if (!options.allowImages) {
|
||||
return replaceRemoteImages(sanitized)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un élément HTML est un placeholder d'image généré par sanitizeMailHtml.
|
||||
* Utile dans MailMessageViewer pour gérer le clic "Afficher l'image".
|
||||
*/
|
||||
export function isMailImagePlaceholder(el: HTMLElement): boolean {
|
||||
return el.hasAttribute('data-mail-image-placeholder')
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le src original d'un placeholder d'image.
|
||||
*/
|
||||
export function getMailImageSrc(el: HTMLElement): string | null {
|
||||
return el.getAttribute('data-mail-image-src')
|
||||
}
|
||||
Reference in New Issue
Block a user