Après l’optimisation des performances du blog (AVIF, pré-compression), j’étais super content de moi. 🥳
Mais c’était sans compter sur Antoine Caron, qui est venu (à très juste titre) me chatouiller sur un autre aspect que j’avais ignoré, le rapport Mozilla Observatory…

J’ai donc lancé un scan Mozilla HTTP Observatory sur mon blog et le résultat était : F.
Cool, je peux pas faire pire.

Le déclic
En relisant l’article de Julien sur codeka.io, qui a aussi parlé avec Antoine, a aussi un blog statique avec Hugo, MAIS qui lui avait fait le taf, j’ai réalisé que c’était à la fois important et peut-être pas si compliqué à corriger.
Julien y détaille les headers de sécurité à ajouter sur un site Hugo, avec des exemples pour Caddy. Moi je suis sur nginx, mais le principe est le même.
Note : si ça vous intéresse, les liens des épisodes précédents sur l’infra du blog sont ici :
- Ça bouge pas mal sur le blog ! (2019) - De 5s à 1s en virant Wordpress pour Hugo
- Ça bouge encore sur le blog (2025) - Nettoyage massif, retour auto-hébergé
- Automatiser son site Hugo sans Github Action ou Vercel - Le setup nginx + webhook actuel
- Optimisation webperf : AVIF et pré-compression - Le round précédent (focus sur les images)
Ce que teste HTTP Observatory
HTTP Observatory vérifie une série de headers HTTP que votre serveur devrait envoyer pour protéger vos visiteurs. Les principaux :
- Strict-Transport-Security (HSTS) : force le navigateur à toujours utiliser HTTPS
- Content-Security-Policy (CSP) : contrôle quelles ressources le navigateur peut charger (scripts, styles, images, iframes…)
- X-Content-Type-Options : empêche le navigateur de “deviner” le type MIME des fichiers
- Referrer-Policy : contrôle les informations de provenance envoyées aux sites tiers
- X-Frame-Options / frame-ancestors : empêche l’inclusion de votre site dans une iframe (clickjacking)
Sur un blog statique, on pourrait se dire que ce n’est pas critique. Pas de base de données, pas de sessions utilisateur, pas de formulaires sensibles. Mais ces headers protègent quand même contre des attaques réelles :
- Un script injecté (XSS) pourrait rediriger vos lecteurs vers un site malveillant
- Sans HSTS, un attaquant sur un Wi-Fi public pourrait intercepter la connexion initiale en HTTP
- Sans
frame-ancestors, quelqu’un pourrait inclure le blog dans une iframe piégée
Bref, même pour un blog statique, ça peut valoir le coup de faire l’effort, a minima pour s’habituer aux bonnes pratiques.
Étape 1 : les headers “faciles”
Le piège nginx : add_header et l’héritage
La première chose, c’est que je suis tombé dans un piège classique avec mon nginx. Je l’ignorais, mais les directives add_header dans un bloc location écrasent (et ne complètent pas) celles du bloc server parent.
Ça veut dire que si vous avez un add_header Cache-Control dans une location (c’était mon cas), tous vos headers de sécurité définis au niveau server disparaissent pour cette location. J’ai pété un plomb pendant quelques minutes à reload / restart en boucle sans comprendre.
La solution : créer un fichier snippet et l’inclure partout.
cat > /etc/nginx/snippets/security-headers.conf << 'EOF'
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "..." always;
EOF
Puis dans la config nginx :
server {
# Headers de sécurité au niveau server
include /etc/nginx/snippets/security-headers.conf;
# Cache des assets statiques
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
# OBLIGATOIRE : ré-inclure les headers ici !
include /etc/nginx/snippets/security-headers.conf;
}
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
include /etc/nginx/snippets/security-headers.conf;
}
}
HSTS, X-Content-Type-Options, Referrer-Policy
Ces trois-là sont les plus simples à ajouter et “ne cassent rien” :
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
- HSTS avec
max-age=63072000(2 ans) etpreloadpermet à terme (pas encore fait) de s’inscrire sur la HSTS preload list. Le navigateur refusera même la toute première connexion HTTP initiale. - nosniff empêche les navigateurs d’interpréter un fichier texte comme du JavaScript.
- strict-origin-when-cross-origin envoie l’URL complète en referrer pour les requêtes same-origin, mais seulement l’origine (domaine) pour les requêtes cross-origin.
Étape 2 : Content-Security-Policy (la vraie difficulté)
La CSP est un header puissant et a priori complexe. C’est lui qui rapporte le plus de points sur Observatory, mais c’est aussi lui qui peut tout casser si on va trop vite.
Le principe : vous déclarez au navigateur exactement quelles ressources votre site a le droit de charger (scripts, styles, images, iframes, fonts…) et depuis quelles origines. Tout le reste est bloqué. C’est la meilleure défense contre le XSS, parce que même si un attaquant arrive à injecter un <script> dans votre HTML, le navigateur refusera de l’exécuter s’il n’est pas dans la liste autorisée.
Le revers de la médaille : si vous oubliez une ressource légitime dans votre CSP, elle sera bloquée et votre site casse silencieusement : un script qui ne charge plus, une font qui disparaît, une iframe vide. Il faut donc faire un inventaire exhaustif avant de verrouiller.
Je me suis fait épauler par un LLM (Opus 4.6) pour les modifs à réaliser et on a fait plusieurs passes jusqu’à ce que tout fonctionne.
Inventaire des ressources externes
Avant d’écrire la CSP, il faut faire l’inventaire de tout ce que le site charge. Voilà une petite synthèse de mon pote Claudio :
| Type | Ressource | Origine |
|---|---|---|
| Script | GoatCounter analytics | https://gc.zgo.at/count.js |
| Script | Umami analytics | https://cloud.umami.is/script.js |
| Connect | GoatCounter API | https://blogzwindler.goatcounter.com |
| Connect | Umami API | https://api-gateway.umami.dev |
| Script | De mon thème ‘stack’ - vibrant.js (couleurs d’images) | https://cdn.jsdelivr.net |
| Script | De mon thème ‘stack’ - PhotoSwipe (lightbox images) | https://cdn.jsdelivr.net |
| Style | De mon thème ‘stack’ - PhotoSwipe CSS (lightbox) | https://cdn.jsdelivr.net |
| Style | De mon thème ‘stack’ - CSS du thème | local ('self') |
| Font | Luciole | locale |
| Frame | Widget Deezer (inclu dans un seul article 😖) | https://widget.deezer.com |
| Image | data: URIs (SVGs inline) | data: |
| Form | Mailchimp (abonnement newsletter) | https://zwindler.us15.list-manage.com |
Le problème des scripts d’analytics externes
Avec une CSP comme script-src 'self' https://gc.zgo.at https://cloud.umami.is, je devais soit :
- Faire confiance à ces CDN -> si le CDN est compromis, le script malveillant s’exécute sur mon blog. Nope.
- Ajouter des hashes SRI (Subresource Integrity). Problème : ces scripts peuvent changer côté serveur sans prévenir, ce qui casserait le hash.
J’ai choisi une troisième voie : télécharger les scripts à chaque build et les servir localement.
# Dans mon petit script blog_refresh.sh, avant le hugo --minify
mkdir -p static/js
curl -sf --max-time 10 https://gc.zgo.at/count.js -o static/js/count.js \
|| echo "WARN: failed to download GoatCounter script"
curl -sf --max-time 10 https://cloud.umami.is/script.js -o static/js/umami.js \
|| echo "WARN: failed to download Umami script"
Et dans le template Hugo :
<script data-goatcounter="https://blogzwindler.goatcounter.com/count"
async src="/js/count.js"></script>
<script defer src="/js/umami.js"
data-website-id="3d8f0ea9-..."></script>
Est-ce que c’est “tricher” ?
Une fois que j’avais fait la modif, je me suis demandé : est-ce qu’héberger les scripts localement à chaque build ne contourne pas l’esprit des vérifications d’Observatory ? Sur ce point précis j’ai un petit doute, n’étant pas expert sur ces points là. J’ai demandé à un LLM qui m’a dit que non, mais je veux bien un avis tiers.
Dans tous les cas, ça semble être l’approche recommandée par la documentation GoatCounter pour les sites avec une CSP stricte.
Supprimer unsafe-inline de script-src
Observatory pénalise fortement 'unsafe-inline' dans script-src. Et c’est normal, car c’est la porte ouverte au XSS : n’importe quel script injecté dans le HTML s’exécute.
Le problème : mon thème Hugo (hugo-theme-stack) générait 4 scripts inline :
- Color scheme init — lit le localStorage pour appliquer le thème clair/sombre avant le premier rendu
- Color scheme detection — détecte la préférence système (
prefers-color-scheme: dark) - Language switcher — le sélecteur de langue
<select onchange="window.location.href=this.selectedOptions[0].value">dans la sidebar, qui est un attribut d’événement inline (script-src-attr) - Font loader — charge la CSS de la font Luciole dynamiquement
Luciole: A typeface for visual impairment
Pour chacun, j’ai extrait le code dans un fichier .js externe dans static/js/ et remplacé le script inline par une balise <script src="...">.
Par exemple, pour le color scheme, le thème avait dans layouts/partials/head/colorScheme.html :
<script>
(function() {
const colorSchemeKey = 'StackColorScheme';
if(!localStorage.getItem(colorSchemeKey)){
localStorage.setItem(colorSchemeKey, "auto");
}
})();
/* ... */
</script>
Que j’ai remplacé par un override de template Hugo :
{{- /* Override: external JS file instead of inline (CSP compliance) */ -}}
<script src="/js/color-scheme.js"></script>
Avec le contenu correspondant dans static/js/color-scheme.js. Hugo permet de surcharger n’importe quel template du thème en plaçant un fichier au même chemin dans le dossier layouts/ du projet.
Pour le language switcher, même approche : j’ai surchargé layouts/partials/sidebar/left.html pour retirer l’attribut onchange du <select> et ajouté un id="language-select", puis créé un fichier static/js/language-switcher.js :
(function () {
var select = document.getElementById('language-select');
if (select) {
select.addEventListener('change', function () {
window.location.href = this.selectedOptions[0].value;
});
}
})();
Note importante : il m’a fallu plusieurs allers-retours avec la console du navigateur (F12 -> Console) avant de trouver la totalité des violations CSP. Chaque correction en révélait de nouvelles, il fallait bien regarder tous les différents types de pages sur le blog (pas juste la page d’accueil et les posts), et certaines erreurs affichées dans la console venaient en fait d’extensions navigateur (typiquement les gestionnaires de mots de passe comme Bitwarden, dont le bootstrap-autofill-overlay.js génère des violations style-src-elem) et non du site lui-même. Il faut donc bien distinguer les erreurs du site de celles injectées par les extensions.
Supprimer unsafe-inline de style-src, la suite
Même logique pour les styles. J’avais quatre sources d’inline CSS :
- Un bloc
<style>danscustom.html— les CSS custom properties pour ma font Luciole - Des attributs
style=""sur les badges de catégories —background-color: #2a9d8f; color: #fff; - Un attribut
style="enable-background:..."sur un SVG (export Adobe Illustrator) - Des attributs
style=""dans le formulaire Mailchimp du footer —display:noneet un positionnement off-screen pour le honeypot anti-spam
Le <style> bloc : déplacé dans assets/scss/custom.scss, le fichier de personnalisation SCSS prévu par le thème.
/* Font family CSS custom properties */
:root {
--article-font-family: "Luciole";
--base-font-family: "Luciole";
/* ... */
}
Les badges de catégories : toutes mes catégories utilisent les mêmes couleurs (#2a9d8f / #fff), définies dans le front matter de chaque _index.md. Le thème les injectait en style="" via le template article/components/details.html. J’ai surchargé ce template pour retirer le style inline et ajouté une règle CSS dans custom.scss :
.article-category a {
background-color: #2a9d8f;
color: #fff;
}
Le SVG : j’avais un SVG que j’avais ajouté à la main (un petit Robot mignon pour mon “AI Manifesto”) avec enable-background, qui est un attribut SVG 1.1 déprécié. Je l’ai simplement supprimé du fichier SVG source, sans impact visuel.
Le formulaire Mailchimp : le code HTML Mailchimp utilise style="display:none" pour masquer les divs de feedback et style="position: absolute; left: -5000px;" pour le champ honeypot (pour les robots qui s’inscrivent à ma newsletter 🤔). J’ai remplacé tout ça par des classes CSS dans custom.scss :
#mce-error-response,
#mce-success-response {
display: none;
}
.mce-honeypot {
position: absolute;
left: -5000px;
}
La CSP finale
Après tous ces changements, plus aucun script ni style inline sur le site. La CSP peut donc être stricte :
default-src 'none';
script-src 'self' https://cdn.jsdelivr.net;
style-src 'self' https://cdn.jsdelivr.net;
img-src 'self' data:;
connect-src 'self' https://blogzwindler.goatcounter.com https://api-gateway.umami.dev https://cdn.jsdelivr.net;
font-src 'self';
frame-src https://widget.deezer.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self' https://zwindler.us15.list-manage.com;
manifest-src 'self';
media-src 'self'
Points notables :
default-src 'none': par défaut, rien n’est autorisé. Chaque type de ressource doit être explicitement listé. C’est la politique la plus restrictive.- Pas de
unsafe-inlinenulle part : ni dansscript-src, ni dansstyle-src frame-ancestors 'none'remplaceX-Frame-Options: DENY(plus moderne)cdn.jsdelivr.netdansscript-src,style-srcETconnect-src: le thème chargevibrant.js(JS avec SRI), les CSS de PhotoSwipe (lightbox d’images) et les source maps associées depuis ce CDNform-actionautorise Mailchimp pour le formulaire d’abonnement dans le footer
Au final, de nombreux morceaux du thème de mon blog ont du être réécrit / overridés. Je suis un peu gêné d’avoir du en arriver là, mais c’était le prix à payer pour une note maximale 😂.

Nettoyage bonus : Mailchimp
En faisant l’inventaire des ressources externes, j’ai découvert un vieux widget Mailchimp dans la sidebar qui chargeait du CSS et du JavaScript depuis chimpstatic.com. Je ne l’utilisais plus : supprimé. Le formulaire d’abonnement dans le footer des articles a été gardé, mais nettoyé de ses style="" inline (voir plus haut).
Résultat final

De F (0/100) à A+ (125/100). Tous les tests sont verts.
Un grand merci à Antoine pour sa patience avec les débutants naïfs comme moi 😂 et ses super talks !