Featured image of post De F à A+ sur HTTP Observatory : sécuriser les headers de mon blog Hugo

De F à A+ sur HTTP Observatory : sécuriser les headers de mon blog Hugo

Ecrit par ~ zwindler ~

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…

https://bsky.app/profile/slashgear.dev/post/3mfc62ckw3s2g

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 scan initial : F avec 0/100

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 :

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) et preload permet à 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 :

TypeRessourceOrigine
ScriptGoatCounter analyticshttps://gc.zgo.at/count.js
ScriptUmami analyticshttps://cloud.umami.is/script.js
ConnectGoatCounter APIhttps://blogzwindler.goatcounter.com
ConnectUmami APIhttps://api-gateway.umami.dev
ScriptDe mon thème ‘stack’ - vibrant.js (couleurs d’images)https://cdn.jsdelivr.net
ScriptDe mon thème ‘stack’ - PhotoSwipe (lightbox images)https://cdn.jsdelivr.net
StyleDe mon thème ‘stack’ - PhotoSwipe CSS (lightbox)https://cdn.jsdelivr.net
StyleDe mon thème ‘stack’ - CSS du thèmelocal ('self')
FontLuciolelocale
FrameWidget Deezer (inclu dans un seul article 😖)https://widget.deezer.com
Imagedata: URIs (SVGs inline)data:
FormMailchimp (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 :

  1. Color scheme init — lit le localStorage pour appliquer le thème clair/sombre avant le premier rendu
  2. Color scheme detection — détecte la préférence système (prefers-color-scheme: dark)
  3. 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)
  4. 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 :

  1. Un bloc <style> dans custom.html — les CSS custom properties pour ma font Luciole
  2. Des attributs style="" sur les badges de catégories — background-color: #2a9d8f; color: #fff;
  3. Un attribut style="enable-background:..." sur un SVG (export Adobe Illustrator)
  4. Des attributs style="" dans le formulaire Mailchimp du footer — display:none et 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-inline nulle part : ni dans script-src, ni dans style-src
  • frame-ancestors 'none' remplace X-Frame-Options: DENY (plus moderne)
  • cdn.jsdelivr.net dans script-src, style-src ET connect-src : le thème charge vibrant.js (JS avec SRI), les CSS de PhotoSwipe (lightbox d’images) et les source maps associées depuis ce CDN
  • form-action autorise 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

A+ sur HTTP Observatory

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 !

Licensed under CC BY-SA 4.0

Vous aimez ce blog ou cet article ? Partagez-le avec vos amis !   Twitter Linkedin email Facebook

Vous pouvez également vous abonner à la mailing list des articles ici

L'intégralité du contenu appartenant à Denis Germain (alias zwindler) présent sur ce blog, incluant les textes, le code, les images, les schémas et les supports de talks de conf, sont distribués sous la licence CC BY-SA 4.0.

Les autres contenus (thème du blog, police de caractères, logos d'entreprises, articles invités...) restent soumis à leur propre licence ou à défaut, au droit d'auteur. Plus d'informations dans les Mentions Légales

Généré avec Hugo
Thème Stack conçu par Jimmy