Contenu

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 \
  | base64

On 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
fi

Un 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 :

  1. Backup de data/sri.yaml + assets/data/cdn/jsdelivr.yml
  2. Bump de la version dans jsdelivr.yml (URL + commentaire)
  3. Fetch + recalcul du nouveau hash SRI
  4. Mise à jour de data/sri.yaml avec la nouvelle URL et le nouveau hash
  5. hugo --minify --cleanDestinationDir
  6. bash deploy.sh (rsync vers nginx)
  7. Purge Cloudflare via API (purge_everything: true, zone arleo.eu)
  8. Vérification live : re-check de tous les hashes depuis jsdelivr CDN
  9. Si OK → heartbeat BetterStack pingé, incident précédent auto-résolu
  10. 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é silencieusement
  • 5xx ou 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.yaml

Pas 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