SRI sur Hugo : hashes automatiques, auto-update et alerting BetterStack

Pourquoi le SRI ?
Quand votre site charge des ressources depuis un CDN tiers — FontAwesome, Mermaid, Animate.css — vous faites confiance à un tiers sur lequel vous n’avez aucun contrôle. Si jsdelivr.net est compromis ou si une version « immuable » est mutée silencieusement, votre site peut devenir vecteur d’attaque.
Le Subresource Integrity (SRI) résout ça simplement : chaque balise <link> ou <script> porte un attribut integrity="sha256-…" que le navigateur vérifie avant d’exécuter la ressource. Si le hash ne correspond pas, le navigateur bloque le chargement.
Sur arleo.eu, le thème LoveIt charge 9 URLs jsdelivr via ses partials Hugo. Aucune n’avait de hash SRI. Ce post documente comment j’ai corrigé ça — et comment j’ai poussé le concept jusqu’à un pipeline entièrement automatisé.
Architecture LoveIt et le problème CDN
LoveIt ne code pas ses URLs CDN en dur dans les templates. L’architecture est la suivante :
themes/LoveIt/assets/data/cdn/jsdelivr.yml ← liste des libs et versions
themes/LoveIt/layouts/_partials/init.html ← charge le YAML, construit $cdn
themes/LoveIt/layouts/_partials/plugin/script.html ← émet les <script>
themes/LoveIt/layouts/_partials/plugin/style.html ← émet les <link>Le fichier jsdelivr.yml ressemble à :
prefix:
libFiles: https://cdn.jsdelivr.net/npm/
libFiles:
fontawesomeCSS: "@fortawesome/fontawesome-free@7.2.0/css/all.min.css"
animateCSS: animate.css@4.1.1/animate.min.css
mermaidJS: mermaid@11.15.0/dist/mermaid.min.js
# ...Les partials plugin/script.html et plugin/style.html émettent les balises sans integrity=. Ce sont eux qu’il faut surcharger — sans toucher au submodule git du thème.
Étape 1 — Calcul des hashes SRI
Pour chaque URL jsdelivr identifiée dans public/*.html :
curl -fsSL https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js \
| openssl dgst -sha256 -binary \
| base64On stocke tous les hashes dans un fichier data/sri.yaml propre au site (pas dans le thème) :
"https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js": "sha256-cBN+d7snO7LvlyuG6LBADMqL5TyyW/xFkRoYbcmGZd4="
"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@7.2.0/css/all.min.css": "sha256-MVopmdyC2tYTiJ8wlktf0uh0v4NgT+vNdyVFepi7Q0c="
# ...Étape 2 — Surcharge des partials LoveIt
Hugo résout les partials en priorité dans le dossier layouts/ du site avant le thème. On crée deux fichiers qui surchargent les originaux :
layouts/_partials/plugin/script.html (extrait) :
{{- $sri := site.Data.sri | default dict -}}
{{- $src := .Source -}}
{{- $hash := index $sri $src | default "" -}}
{{- if and $src (hasPrefix $src "http") $hash -}}
<script src="{{ $src }}" crossorigin="anonymous" integrity="{{ $hash }}"
{{- with .Defer }} defer{{ end -}}
{{- with .Async }} async{{ end -}}
></script>
{{- else if and $src (hasPrefix $src "http") -}}
<script src="{{ $src }}"
{{- with .Defer }} defer{{ end -}}
{{- with .Async }} async{{ end -}}
></script>
{{- else -}}
{{/* ressource locale — inchangée */}}
{{- end -}}La logique est simple : si l’URL est présente dans data/sri.yaml, on injecte integrity= + crossorigin="anonymous". Sinon, on laisse passer sans modifier. Les ressources locales (fingerprinting Hugo) ne sont pas touchées.
Même logique pour plugin/style.html, avec propagation des attributs sur les deux balises <link> générées (preload + stylesheet).
Après un hugo --minify, toutes les balises CDN portent leur hash :
<script src="https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js"
crossorigin="anonymous"
integrity="sha256-cBN+d7snO7LvlyuG6LBADMqL5TyyW/xFkRoYbcmGZd4=">Étape 3 — Script de surveillance hebdomadaire
Avoir des hashes SRI c’est bien. Les garder à jour sans intervention manuelle, c’est mieux. Le script /home/jm/scripts/check-sri-versions.sh (cron lundi 8h) fait trois choses :
3.1 Vérification des hashes live
Pour chaque URL de data/sri.yaml, le script recalcule le hash en direct et compare :
live=$(curl -fsSL --max-time 30 "$url" \
| openssl dgst -sha256 -binary | base64)
if [ "sha256-$live" != "$stored" ]; then
# WARN — hash mismatch : CDN muté ou compromis
fiUn mismatch sur une version épinglée (ex. mermaid@11.15.0) signifie que jsdelivr a modifié un fichier théoriquement immuable — c’est un signal de sécurité critique. Ce cas n’est jamais auto-fixé : il crée un incident BetterStack pour review humaine.
3.2 Détection des versions outdated
Pour chaque lib active dans public/*.html, le script interroge l’API jsdelivr :
latest=$(curl -fsSL "https://data.jsdelivr.com/v1/packages/npm/$pkg" \
| jq -r '.tags.latest')Les libs inactives (définies dans jsdelivr.yml mais non activées dans hugo.toml) sont loggées INFO (inactive, skipped) sans déclencher d’alerte — ce qui évite le bruit sur les features optionnelles de LoveIt.
Trois catégories de résultat :
| Situation | Action |
|---|---|
| Version à jour | OK |
| Minor/patch outdated (même major) | Auto-fix complet |
| Major bump (ex. echarts 5→6) | WARN → incident humain |
3.3 Auto-fix minor/patch
C’est la partie intéressante. Pour une lib minor/patch outdated, le script :
- Backup de
data/sri.yaml+assets/data/cdn/jsdelivr.yml - Bump de la version dans
jsdelivr.yml(URL + commentaire) - Fetch + recalcul du nouveau hash SRI
- Mise à jour de
data/sri.yamlavec la nouvelle URL et le nouveau hash hugo --minify --cleanDestinationDirbash deploy.sh(rsync vers nginx)- Purge Cloudflare via API (
purge_everything: true, zone arleo.eu) - Vérification live : re-check de tous les hashes depuis jsdelivr CDN
- Si OK → heartbeat BetterStack pingé, incident précédent auto-résolu
- Si échec à n’importe quelle étape → rollback (restore backup + rebuild + redeploy + repurge CF) + incident BetterStack
# Extrait — purge Cloudflare
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"purge_everything":true}'Le token CF est réutilisé depuis /etc/crowdsec/cf-sync.env (scope Zone:Cache Purge vérifié au préalable via /user/tokens/verify).
Étape 4 — Intégration BetterStack
Le script suit un cycle d’alerting complet :
Run WARN
→ POST /api/v2/incidents (summary + description détaillée)
→ ID de l'incident écrit dans ~/.config/sri-check.open-incidents (chmod 600)
Run OK suivant
→ POST /api/v2/incidents/{id}/resolve pour chaque ID tracké
→ ~/.config/sri-check.open-incidents supprimé
→ GET heartbeat BetterStack pingéRobustesse du state file :
404(incident supprimé manuellement côté BS) → dropé silencieusement5xxou réseau down → ID conservé pour retry au prochain run- Plusieurs incidents en parallèle → tous résolus au prochain run OK
Fichiers créés/modifiés
| Fichier | Rôle |
|---|---|
data/sri.yaml |
Map URL → hash sha256 (source de vérité SRI) |
assets/data/cdn/jsdelivr.yml |
Override local du YAML du thème (versions bumped) |
layouts/_partials/plugin/script.html |
Surcharge LoveIt — injection integrity= sur <script> |
layouts/_partials/plugin/style.html |
Surcharge LoveIt — injection integrity= sur <link> |
/home/jm/scripts/check-sri-versions.sh |
Script de surveillance + auto-fix (cron lundi 8h) |
/home/jm/.config/sri-check.env |
Secrets BS + CF (chmod 600) |
/home/jm/.config/sri-check.open-incidents |
State file incidents (créé/supprimé à la volée) |
/etc/logrotate.d/sri-check |
Rotation weekly, 4 semaines, compress |
Aucune modification dans themes/LoveIt/ (submodule git intact).
Pour étendre à de nouvelles libs
Ajouter une ligne dans data/sri.yaml suffit :
url="https://cdn.jsdelivr.net/npm/nouvelle-lib@1.2.3/dist/lib.min.js"
hash=$(curl -fsSL "$url" | openssl dgst -sha256 -binary | base64)
echo "\"$url\": \"sha256-$hash\"" >> data/sri.yamlPas de template à toucher. Pas de rebuild manuel — le prochain cron s’en charge.
Résultat
- 9 libs CDN protégées par SRI, hashes injectés automatiquement à chaque build Hugo
- 0 modification dans le submodule LoveIt
- Pipeline entièrement automatisé : détection → bump → rebuild → deploy → purge CF → vérif live → alerting BS → auto-résolution
- Score ImmuniWeb/Qualys : A+ maintenu, disparition des avertissements SRI manquants
Plugin hugo-mcp sri-check
Le script bash a depuis été intégré comme plugin natif du serveur MCP hugo-mcp. Il expose le tool check_sri_versions(auto_fix, dry_run) directement depuis Claude.ai, et délègue la purge Cloudflare au plugin CF existant plutôt que de l’appeler en direct depuis le bash.
Code source et PR : github.com/jmrGrav/hugo-mcp — PR #1 feat/sri-check-plugin