Ajouter un nonce CSP frais à un site statique avec nginx
CentralCSP Team ·
Dernière mise à jour:
Une politique de sécurité du contenu (CSP) stricte repose sur un nonce : une valeur aléatoire qui change à chaque réponse, envoyée dans le header Content-Security-Policy et répétée sur chaque balise <script> de confiance. Une application dynamique génère cette valeur au moment du rendu de la page. Un site statique, lui, ne rend rien, et nginx n'a aucun moyen intégré de générer un nonce, d'où le conseil habituel : « on ne peut pas mettre de nonce sur un site statique ». Si, on peut. Vous placez un marqueur non devinable dans votre HTML et vous laissez nginx le remplacer par un nonce frais au moment de servir la page.
L'idée en une phrase : votre HTML contient un marqueur fixe dans chaque <script nonce="...">, et à chaque requête nginx génère un nonce, l'écrit dans le header CSP, puis utilise sub_filter pour remplacer le marqueur dans le HTML par cette même valeur. Le même nonce dans le header et dans les balises, frais à chaque fois, avec quelques directives nginx.
Vous découvrez les nonces ? Comment mettre en place un nonce CSP, par requête présente d'abord le concept côté frameworks applicatifs ; cet article est la version nginx seul, pour site statique.
Pourquoi un site statique a besoin d'un contournement
Un nonce CSP doit être unique par réponse et non devinable, et exactement la même valeur doit apparaître à deux endroits : la source script-src 'nonce-...' et chaque <script nonce="..."> de confiance. Si elles ne correspondent pas, le navigateur bloque le script.
Un framework applicatif fait cela naturellement : il crée une valeur aléatoire par requête et la dépose à la fois dans le header et dans le template. Un site statique n'a pas d'étape équivalente. Les fichiers sur le disque sont fixes, et nginx les sert tels quels. nginx n'a pas non plus de fonctionnalité native de nonce, vous devez donc fabriquer vous-même la valeur par requête et l'insérer dans la réponse. sub_filter (un filtre léger de réécriture du corps de réponse) associé au $request_id par requête de nginx couvre ces deux besoins.
Étape 1 : placer un marqueur non devinable dans votre HTML
Choisissez un jeton aléatoire, une seule fois, et utilisez-le comme valeur de nonce sur chaque script de confiance de votre HTML statique. Générez-le avec n'importe quel outil qui produit une longue chaîne aléatoire :
openssl rand -hex 16Supposons que cela vous donne 9f2c8a1b7e4d60359f2c8a1b7e4d6035. Utilisez-le comme marqueur partout où un script a besoin d'un nonce :
<script nonce="__csp_nonce_9f2c8a1b7e4d60359f2c8a1b7e4d6035__" src="/app.js"></script>
<script nonce="__csp_nonce_9f2c8a1b7e4d60359f2c8a1b7e4d6035__">init();</script>Le marqueur est une constante de build, identique dans votre HTML et dans votre configuration nginx. Ce n'est pas le nonce ; c'est le repère que nginx cherche et écrase.
Le marqueur doit être non devinable, et c'est une exigence de sécurité, pas un choix de style. Supposons que vous utilisiez une chaîne connue de tous comme **CSP_NONCE**, et que du contenu contrôlé par un attaquant puisse atteindre le corps de la réponse avant l'exécution de sub_filter : un paramètre de requête réfléchi dans la page, un champ de commentaire ou de profil intégré dans du HTML « statique », un server-side include, un CDN qui réécrit le corps. L'attaquant n'a plus qu'à injecter <script nonce="**CSP_NONCE**">evil()</script>, et nginx apposera le vrai nonce, valide, sur son script. Son code devient de confiance et la protection du nonce disparaît. Un long jeton aléatoire qu'il ne peut pas deviner ferme cette porte. sub_filter retire le marqueur avant l'envoi de la réponse, il n'apparaît donc jamais dans le source de la page ; il ne reste que la devinette, et c'est précisément ce qu'un jeton non devinable rend impossible. Un site entièrement statique sans contenu réfléchi présente un risque plus faible, mais le jeton ne coûte rien et la technique reste sûre si le site cesse un jour d'être parfaitement statique.
Ne placez le marqueur que sur vos propres scripts de confiance. Ne configurez pas nginx pour poser aveuglément un nonce sur chaque <script de la page (voir les pièges), car cela accorderait aussi la confiance à des scripts inline injectés.
Étape 2 : générer et injecter le nonce dans nginx
Trois directives font le travail : capturer une valeur par requête, réécrire le marqueur avec cette valeur, et envoyer le header avec la même valeur.
server {
listen 443 ssl;
root /var/www/static;
# A fresh, unique value per request
set $cspNonce $request_id;
location / {
# Replace the placeholder in every trusted tag with the nonce
sub_filter '__csp_nonce_9f2c8a1b7e4d60359f2c8a1b7e4d6035__' '$cspNonce';
sub_filter_once off;
# Send the matching nonce in the policy
add_header Content-Security-Policy "default-src 'self'; script-src 'nonce-$cspNonce' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
}
}$request_id est une variable native de nginx : 16 octets aléatoires rendus en 32 caractères hexadécimaux, régénérés à chaque requête, sans module supplémentaire. C'est une valeur de nonce valide : unique par réponse et imprévisible. sub_filter trouve le marqueur dans le corps HTML et le remplace par $cspNonce, et sub_filter_once off lui fait remplacer chaque occurrence plutôt que seulement la première. La ligne add_header place le même $cspNonce dans script-src comme source de nonce, aux côtés de 'strict-dynamic' pour qu'un script de confiance puisse charger le reste sans avoir à lister des hôtes.
Les pièges qui font mal
Ça fonctionne, mais quelques comportements de nginx cassent le montage en silence si vous les ignorez.
sub_filtera besoin de son module. Il provient dengx_http_sub_module, intégré à la plupart des paquets de distribution. Si le vôtre en manque, nginx doit être compilé avec--with-http_sub_module.- Il ne réécrit que du HTML non compressé.
sub_filterne peut pas voir à l'intérieur d'un corps gzippé. Servir des fichiers statiques bruts convient, car nginx compresse la réponse après l'exécution desub_filter. Mais si vous servez des fichiers.gzpré-compressés avecgzip_static,sub_filterne voit jamais le balisage, et le marqueur n'est pas remplacé. Si vous passez un jour par un upstream proxifié, désactivez la compression upstream pour cet emplacement avecproxy_set_header Accept-Encoding "";. add_headern'hérite pas toujours. Si un bloclocationcontient unadd_headerqui lui est propre, il cesse d'hériter des directivesadd_headerdu blocserverouhttpenvironnant. Donc si votre CSP est définie plus haut et qu'un emplacement ajoute un autre header, la CSP disparaît discrètement. Définissez le header dans le même bloc, et utilisezalwayspour qu'il soit envoyé aussi sur les réponses en erreur, pas seulement sur les 2xx et 3xx.- Ne faites confiance qu'à vos propres balises. Le modèle du marqueur limite déjà la réécriture aux balises que vous avez marquées. Ne passez pas à la réécriture de chaque
<script, cela remettrait un nonce valide à n'importe quel script inline de la page, y compris un script injecté. $request_idest imprévisible, pas un tirage CSPRNG académique. Chaque worker nginx amorce une clé aléatoire une fois et dérive les request ids à partir d'elle, si bien que les valeurs sont de fait uniques et non devinables, ce qu'exige un nonce. Si vous voulez une valeur tirée directement d'un RNG cryptographique, utilisez les options njs ou OpenResty ci-dessous.
Associez-le à strict-dynamic
Le mot-clé 'strict-dynamic' de l'exemple est ce qui rend un nonce praticable sur un vrai site. Avec lui, vous ne mettez un nonce que sur vos scripts d'entrée ; tout script qu'ils chargent est approuvé automatiquement, et les allowlists d'hôtes (ainsi que 'unsafe-inline') sont ignorées. Sans lui, vous devriez ajouter le nonce à chaque balise de script, y compris celles que votre code injecte à l'exécution, que vous ne pouvez pas atteindre depuis un template statique. Comment fonctionne strict-dynamic couvre les compromis. Gardez object-src 'none' et base-uri 'none' à ses côtés, comme dans la configuration ci-dessus, et construisez le reste de la politique à partir d'un modèle de départ de CSP stricte.
Quand se tourner vers autre chose
La combinaison $request_id et sub_filter est la façon la plus rapide et sans dépendance de mettre un nonce sur un site statique. Deux situations appellent quelque chose de plus solide.
- Vous avez en réalité un backend. Si nginx fait du reverse-proxy vers une application que vous contrôlez, générez plutôt le nonce dans l'application. Elle rend déjà le HTML, elle peut donc écrire une seule valeur à la fois dans les balises
<script nonce>et dans le headerContent-Security-Policy, et nginx se contente de laisser passer le header. Pas de réécriture du corps, pas de surprises de compression. Mettre en place un nonce CSP par requête montre le modèle côté application pour Express et Next.js. - Vous voulez un nonce issu d'un RNG cryptographique et pouvez ajouter un module. Le module njs est fourni avec nginx et sait générer un nonce base64 avec
crypto.getRandomValues, définir le header dansjs_header_filter, et réécrire le corps dansjs_body_filter. OpenResty avec Lua (resty.random.bytesplusngx.ctxpour partager la valeur entre les phases header et corps) fait la même chose. Les deux demandent plus de code quesub_filter, et se heurtent à la même limite : les corps compressés restent hors de portée.
Vérifiez ce que vous avez déployé
Après le déploiement, confirmez que le header et les balises correspondent bien sur une réponse en direct. Passez l'URL dans le scanner CSP pour lire le header que nginx envoie, et dans l'évaluateur CSP pour noter la politique et signaler un script-src faible. Chargez la page et vérifiez la console : un nonce qui ne correspond pas apparaît immédiatement comme une violation de script bloqué, ce qui est le moyen le plus rapide de repérer un marqueur qui n'a pas été remplacé.
Une vérification en console ne couvre que les pages que vous ouvrez vous-même. Comme un nonce qui ne correspond pas est signalé comme une csp-violation ordinaire, pointer la politique vers un endpoint CentralCSP transforme ce contrôle ponctuel en couverture continue : si une réponse en cache sert un jour un marqueur périmé, les violations remontent du trafic réel au lieu d'attendre que vous rechargiez la bonne page.
Sur le même sujet
- Comment mettre en place un nonce CSP, par requête, la version pour framework applicatif
- strict-dynamic expliqué
- Un modèle de départ de CSP à copier et resserrer
- Nonces et hashes, la référence