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