~13 min de lecture

CSP stricte : pourquoi ton app Angular s'affiche toute nue, et comment la rhabiller sans 'unsafe-inline'

L'équipe sécu passe sur ton projet. Verdict de l'audit : il manque une Content Security Policy (CSP), ce header HTTP qui dit au navigateur d'où les scripts, les styles et les images ont le droit de venir, directive par directive (script-src, style-src...), les directives de chargement que tu ne précises pas retombant sur default-src. Quelqu'un pose un default-src 'self' propre sur le reverse proxy, et déploie.

Résultat : ton app s'affiche en Times New Roman sur fond blanc, les composants empilés. Le HTML est là, les données sont là, le router fonctionne. Mais plus un seul style n'est appliqué.

Personne n'a touché au CSS. C'est la CSP qui vient de neutraliser deux mécanismes de base d'Angular, et si tu ne sais pas lesquels, tu vas perdre une après-midi. Voire pire : tu vas "corriger" avec 'unsafe-inline', le mot-clé qui réautorise tout ce qui est écrit directement dans la page, et affaiblir la protection que tu venais d'installer.

Le code cible Angular 20+. ngCspNonce et le token CSP_NONCE existent depuis Angular 16 ; autoCsp est disponible (en préversion) depuis Angular 19.


TL;DR

La parade centrale est le nonce : une valeur aléatoire générée à chaque requête, déclarée dans le header CSP et recopiée sur chaque balise inline légitime. La variante statique, c'est le hash : l'empreinte (SHA-256, 384 ou 512) du contenu exact d'une balise inline ; listée dans le header, elle autorise ce contenu-là et aucun autre.

Situation Parade
Angular SSR, ou serveur capable de substituer une valeur par requête dans l'index Attribut ngCspNonce + nonce par requête, recopié dans le header (style-src ET script-src)
Nonce fourni à l'app par une autre voie que l'index (en pratique : en CSR) Token CSP_NONCE pour ce qu'Angular crée au runtime, plus désactivation de l'inlining de build, un processus qui ne connaît que l'attribut : critical CSS (ces règles de ta feuille globale qu'Angular insère dans la page pour le premier rendu) et polices
Build CSR (rendu entièrement navigateur) : verrouiller script-src sans lister tes bundles security.autoCsp (à base de hashes, préversion, incompatible avec le SSR et le prerender)
SSG servi par un CDN Pas de nonce sans couche dynamique : middleware edge (fonction exécutée par le CDN à chaque requête), ou hashes par page
Déployer une CSP sans risquer la prod Content-Security-Policy-Report-Only d'abord, mode bloquant ensuite

La règle d'or : partout où un nonce ou un hash est possible, 'unsafe-inline' n'est pas une parade, c'est l'absence de parade. Dans script-src, il prive ta CSP de l'essentiel de sa protection contre les XSS (injection de script qui s'exécute chez tes utilisateurs, dans l'origine de ton app) ; dans style-src, il laisse ouvertes les injections CSS : du style injecté qui exfiltre des valeurs de la page via des sélecteurs d'attribut. Et dès qu'un nonce ou un hash est présent, les navigateurs modernes l'ignorent : il ne sert plus que de repli pour les anciens.


Pourquoi ta CSP casse tout : deux mécanismes inline

Mécanisme 1 : les styles de composants. Embarqués dans le bundle JavaScript (styles ou styleUrl, même traitement), ils atterrissent, au premier rendu du composant, dans une balise <style> que le renderer pose dans le <head> :

import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-pricing-card',
  template: `<article class="card"><ng-content /></article>`,
  styles: `
    .card { border-radius: 12px; padding: 1.5rem; }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PricingCard {}

Ton <head> contient alors :

<style>.card[_ngcontent-ng-c2807643909] { border-radius: 12px; padding: 1.5rem; }</style>

Et une balise <style> sans nonce ni hash autorisé, pour une CSP, c'est du style inline : bloqué.

Mécanisme 2 : le critical CSS de ta feuille globale. Tu pourrais croire ton styles.css à l'abri puisqu'il sort en fichier externe. Non : en build de prod, optimization.styles.inlineCritical vaut true par défaut. Angular extrait les règles critiques pour le premier rendu et les insère dans une balise <style> de l'index, puis charge la feuille complète en différé :

<style>body{background:#010203;...}</style>
<link rel="stylesheet" href="styles-XYZ.css" media="print" onload="this.media='all'">

Le <link> arrive en media="print" pour ne pas bloquer le rendu, et le onload le bascule sur all une fois la feuille chargée. Une CSP stricte bloque les deux : le <style> inline, et le onload, qui est du script inline. Ta feuille globale reste appliquée à la seule impression. D'où la page toute nue de l'intro : composants ET styles globaux.

Chrome le dit en console, un message par balise bloquée (une seule ligne, repliée ici, hash abrégé) :

Refused to apply inline style because it violates the following Content
Security Policy directive: "default-src 'self'". Either the 'unsafe-inline'
keyword, a hash ('sha256-Oy93TcQyyCn3QGdsy...'), or a nonce ('nonce-...') is required to
enable inline execution. Note also that 'style-src' was not explicitly set,
so 'default-src' is used as a fallback.

Le hash que Chrome te propose, c'est celui du TL;DR, calculé sur la balise bloquée. Fausse piste à l'échelle d'une app : il t'en faut un par composant, et le contenu de cette balise, certes stable d'un build à l'autre, change dès que tu retouches les styles du composant, mais aussi dès que son identifiant _ngcontent-ng-c... bouge (structure du template, inputs, méthode ajoutée à la classe). Une CSP de dizaines de hashes à régénérer en continu, personne ne la maintient. La vraie réponse, c'est le nonce.


Parade 1 : ngCspNonce, le nonce par requête (SSR ou serveur qui génère la page)

Un nonce ne vaut que s'il est généré à chaque requête : figé dans un index déployé, il devient un secret public que n'importe quelle injection peut recopier.

Angular 16+ fournit l'attribut ngCspNonce exactement pour ça. Tu le poses sur l'élément racine de ton app, dans l'index, avec un placeholder :

<!-- src/index.html -->
<body>
  <app-root ngCspNonce="**CSP_NONCE**"></app-root>
</body>

Angular recopie sa valeur en attribut nonce sur chaque <style> qu'il injecte. Et pas seulement : quand l'attribut est présent, le rendu serveur remplace le onload du critical CSS par un petit script porteur du nonce, et pose ce nonce sur les scripts inline de l'event replay (celui qui rejoue les clics reçus avant l'hydratation). Trois scripts inline en tout (un pour le critical CSS, deux pour l'event replay : le contrat, qui porte la mécanique de capture, et le script qui l'arme sur les événements de la page) - quatre si tu as activé l'option de build subresourceIntegrity (celle qui ajoute un attribut integrity sur tes bundles), le build posant alors inline l'import map qui porte les empreintes des chunks lazy : c'est pour ces scripts inline que le nonce va aussi dans script-src. Côté serveur, avec le server.ts moderne de @angular/ssr :

// src/server.ts
import { AngularNodeAppEngine } from '@angular/ssr/node';
import { randomBytes } from 'node:crypto';
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const browserDistFolder = resolve(dirname(fileURLToPath(import.meta.url)), '../browser');

const app = express();
const angularApp = new AngularNodeAppEngine();

// One nonce per request, and the CSP header on every response the app serves
app.use((req, res, next) => {
  const nonce = randomBytes(16).toString('base64');
  res.locals['cspNonce'] = nonce;
  res.setHeader(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      `style-src 'self' 'nonce-${nonce}'`,
      `script-src 'self' 'nonce-${nonce}'`,
      "object-src 'none'",
      "base-uri 'self'",
    ].join('; '),
  );
  next();
});

// Static assets skip the renderer
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));

app.use('/{*splat}', async (req, res, next) => {
  try {
    const response = await angularApp.handle(req);
    if (!response) return next();

    const html = (await response.text()).replaceAll(
      '**CSP_NONCE**',
      res.locals['cspNonce'] as string,
    );
    response.headers.forEach((value, key) => res.setHeader(key, value));
    res.status(response.status).send(html);
  } catch (err) {
    next(err);
  }
});

// ... keep `app.listen` and `export const reqHandler` from the generated server.ts

Le même placeholder sert partout : les <style> et les scripts émis par le rendu serveur le portent en attribut nonce, et le replaceAll couvre tout d'un coup. La CSP, elle, se pose en tête de la chaîne de middlewares pour couvrir aussi les réponses statiques : même index.csr.html (le shell CSR émis par le build, un HTML dont le <app-root> est vide), servi tel quel avec son placeholder en clair, reste sous CSP.

Avant/après, mesuré sous Chromium sur un build de prod. Sans nonce, dans le HTML servi :

<style>.card[_ngcontent-ng-c2807643909]{...}</style>       <!-- bloqué -->
<link rel="stylesheet" href="styles-XYZ.css" media="print"
      onload="this.media='all'">                            <!-- onload bloqué : coincé en print -->

Avec le server.ts ci-dessus :

<style nonce="kQ3vX2FmR8...">.card[_ngcontent-ng-c2807643909]{...}</style>
<link rel="stylesheet" href="styles-XYZ.css" media="print" ngCspMedia="all">
<!-- basculé sur media="all" par un script porteur du nonce -->

Zéro violation en console, feuille globale active, event replay fonctionnel.

Même recette si ton index.html est servi par un backend non Angular capable d'y substituer une valeur par requête : seuls le header et le replaceAll changent de maison.

Ce qui pique, c'est le SSG servi par un CDN : des pages prérendues identiques pour tout le monde, donc pas de nonce par requête sans une couche dynamique. Deux issues : un middleware edge qui injecte le nonce dans le HTML à la volée, ou des hashes.

Les hashes, il en faut beaucoup : ceux des <style> prérendus (critical CSS et composants), ceux des scripts inline, plus 'unsafe-hashes' avec celui du onload (sans ce mot-clé, un hash n'autorise que des balises entières, jamais un gestionnaire d'événement en attribut). L'option est fragile à deux titres : le script d'event replay dépend des événements écoutés par la page, son hash varie donc d'une page à l'autre ; et un composant rendu pour la première fois après le chargement (un @if qui s'ouvre) injecte un <style> absent du HTML prérendu, dont le hash est à calculer en exécutant l'app (le bundle ne porte qu'un marqueur, _ngcontent-%COMP%, que l'identifiant réel du composant remplace au runtime). Détendre seulement style-src avec 'unsafe-inline' ne suffit pas : sans leurs hashes, les scripts inline restent bloqués, et ta feuille globale avec.


Parade 2 : le token CSP_NONCE, quand l'index n'est pas à toi

Si le nonce n'arrive qu'au bootstrap côté client (déposé par un script serveur ou lu dans une config), fournis-le par injection de dépendances. Le cas typique, en CSR : l'index n'est pas à toi - un portail qui embarque ton app, un gabarit mutualisé où tu ne peux poser l'attribut nulle part - et la plateforme expose déjà son nonce au runtime. (Avec le server.ts généré, ce cas est rare en SSR : l'index serveur sort de ton propre build, tu peux y poser l'attribut.) Côté app, ça tient en un provider :

// app.config.ts
import { ApplicationConfig, CSP_NONCE } from '@angular/core';

declare global {
  var appNonce: string;
}

export const appConfig: ApplicationConfig = {
  providers: [
    { provide: CSP_NONCE, useValue: globalThis.appNonce },
  ],
};

Le serveur dépose la valeur dans un script qui porte lui-même le nonce :

<script nonce="**CSP_NONCE**">globalThis.appNonce = '**CSP_NONCE**';</script>

Sous le capot, la factory par défaut de CSP_NONCE ne fait que lire l'attribut d'un élément [ngCspNonce], où qu'il soit dans le body ; fournir le token remplace cette lecture. Mais seulement pour ce qu'Angular crée au runtime via la DI : les styles du renderer, le script de démarrage de l'event replay en SSR, et les requêtes JSONP d'HttpClient (http.jsonp(), qui passe par une balise <script> injectée).

Plusieurs balises inline y échappent, parce que chaque processus qui les pose ne connaît que l'attribut :

  • le critical CSS, inliné par un processeur à part (au build en CSR et SSG, à chaque rendu en SSR) ;
  • les polices Google Fonts ou Typekit, que le build inline aussi par défaut (optimization.fonts.inline) ;
  • en SSR, le contrat de l'event replay, le premier des deux scripts vus plus haut (ng-event-dispatch-contract, posé au build dans l'index serveur) ;
  • si subresourceIntegrity est actif, l'import map vue plus haut (<script type="importmap">), que le build pose inline, sans option pour l'en empêcher. Bloquée, elle ne casse rien à l'écran (les chunks lazy chargent quand même) : tu perds juste, en silence, leur validation d'intégrité.

Avec le token seul, mesuré : la feuille globale reste bloquée (fond transparent, <link> coincé en media="print") ; en SSR, l'event replay casse aussi, car le navigateur refuse le contrat faute de nonce et le script de démarrage échoue à sa suite (window.__jsaction_bootstrap is not a function).

La conclusion est nette. En SSR, le plus simple est de garder ngCspNonce dans l'index, qui couvre tout d'un coup ; à défaut, il faut les trois : le token, optimization.styles.inlineCritical: false et le hash du contrat dans script-src (mesuré : son contenu, indépendant de la page, ne peut changer qu'à une montée de version d'Angular), plus celui de l'import map si subresourceIntegrity est actif, et optimization.fonts.inline: false (avec leurs origines) si ton index charge Google Fonts ou Typekit. En CSR, le token plus la désactivation de l'inlining (optimization.styles.inlineCritical: false, optimization.fonts.inline: false) suffit pour l'inline - mais la feuille Google Fonts redevient alors externe : autorise https://fonts.googleapis.com dans style-src et https://fonts.gstatic.com dans font-src, des origines que default-src 'self' ne couvre jamais, que tu inlines ou non. Avec subresourceIntegrity, son import map, figée au build, se traite par un hash dans script-src, à recalculer à chaque déploiement. En SSG, ni ngCspNonce ni le token ne rattrapent les balises figées dans le HTML prérendu : retour au middleware edge ou aux hashes de la parade 1.


Parade 3 : autoCsp pour les scripts d'un build CSR (préversion)

Les styles ne sont que la moitié du sujet. Une CSP sérieuse verrouille d'abord script-src, et là aussi Angular a un mécanisme dédié depuis la v19, l'option security.autoCsp du builder application :

// angular.json > projects > <app> > architect > build > options
"security": {
  "autoCsp": true
}

Au build, Angular transforme les scripts de ton index.html en chargeurs inline, calcule leurs hashes et émet une balise <meta http-equiv="Content-Security-Policy"> avec un script-src à base de hashes et de 'strict-dynamic' : les scripts hashés ont le droit d'en charger d'autres (tes chunks lazy). Aucun fichier à lister, aucun nonce à générer.

Trois limites : le statut de préversion ; un périmètre restreint aux scripts (pour les styles, tu retombes sur les parades 1 et 2) ; et surtout l'incompatibilité avec le rendu serveur. SSR ou prerender activé, le build échoue net :

✖ Building... [FAILED: Cannot set both SSR and auto-CSP at the same time.]

Autrement dit, c'est la parade du build CSR pur ; en SSR, c'est le nonce qui couvre aussi les scripts.


Déploie en Report-Only d'abord, ou tu vas le regretter

Une CSP se déploie en deux temps, comme une migration risquée. Le header Content-Security-Policy-Report-Only évalue ta CSP en mode observation : les violations sont journalisées (dans la console, et vers un endpoint report-to si tu en configures un) mais rien n'est bloqué.

res.setHeader('Content-Security-Policy-Report-Only', policy);

Tu laisses tourner quelques jours sur du trafic réel, tu découvres les violations imprévues (le widget tiers, le script analytics qui charge un sous-domaine), tu ajustes, puis tu bascules en mode bloquant. Dans l'ordre inverse, c'est ton app qui sert de détecteur de violations, en prod, devant tes utilisateurs.

Dernier piège du même ordre : ne valide jamais ta CSP sur ng serve, dont les mécanismes d'injection (HMR, styles à la volée) n'existent pas en prod. Le seul test qui compte se fait sur le build de prod, servi par ton vrai serveur.


Trusted Types : le second verrou, et ce qu'il casse avec autoCsp

CSP et Trusted Types se complètent : la CSP contrôle d'où vient le code exécutable, Trusted Types ce qui entre dans les API DOM qui interprètent du HTML (innerHTML et compagnie, les sinks). Même header pour les deux : require-trusted-types-for 'script'; trusted-types angular angular#unsafe-bypass. Une policy est un objet nommé, créé via trustedTypes.createPolicy() : c'est le seul moyen de faire accepter une chaîne par ces sinks. Le header en autorise deux : angular pour le framework, angular#unsafe-bypass seulement si ton code appelle bypassSecurityTrust*(). Ce dernier cas est disséqué dans l'article sur DomSanitizer et les XSS.

Une réserve, mesurée sous Angular 22.1 : ce header casse une app autoCsp, dont les chargeurs générés assignent l'URL des scripts en simple chaîne, sans passer par une policy, et se font refuser ; il passe sur une app hydratée classique, SSR comme SSG.


Récap actionnable

  1. Partout où un nonce ou un hash est possible, 'unsafe-inline' n'est pas une parade.
  2. App SSR : ngCspNonce sur <app-root>, nonce généré par requête (randomBytes), recopié dans le header, et dans le HTML via un placeholder. Le nonce va dans style-src ET script-src : les scripts inline d'Angular (critical CSS, event replay) en dépendent. ngCspNonce existe depuis Angular 16 ; le server.ts montré demande AngularNodeAppEngine (v19+) et Express 5 (généré par défaut depuis la v20) pour la route '/{*splat}' - si ton projet est resté en Express 4, garde '/**'.
  3. Index pas à toi (en pratique en CSR) : token CSP_NONCE, plus la désactivation de l'inlining de build : optimization.styles.inlineCritical: false, et optimization.fonts.inline: false si ton index charge Google Fonts ou Typekit - en autorisant leurs origines dans tous les cas (https://fonts.googleapis.com dans style-src, https://fonts.gstatic.com dans font-src). Avec subresourceIntegrity, ajoute aussi le hash de l'import map d'intégrité à script-src. En SSR, garde ngCspNonce, qui couvre tout d'un coup ; à défaut, il faut les trois : le token, optimization.styles.inlineCritical: false et le hash du contrat de l'event replay dans script-src, plus la même clause polices le cas échéant.
  4. SSG sur CDN : pas de nonce sans couche dynamique. Middleware edge qui injecte le nonce par requête, sinon hashes par page (styles prérendus, scripts inline, 'unsafe-hashes' pour le onload), une option fragile.
  5. Build CSR pur : essaie security.autoCsp (Angular 19+, préversion) pour un script-src à base de hashes généré au build. Incompatible avec le SSR et le prerender.
  6. Toujours Report-Only d'abord, sur trafic réel, puis mode bloquant. Et valide sur le build de prod, jamais sur ng serve.

Une CSP bien posée, c'est le garde-fou qui transforme une XSS exploitable en avertissement console. Ça vaut largement une après-midi de configuration, à condition de ne pas la passer à chercher pourquoi tes composants sont tout nus.

📧 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.