Contents

SRI on Hugo: automated hashes, auto-update and BetterStack alerting

Why SRI?

When your site loads resources from a third-party CDN — FontAwesome, Mermaid, Animate.css — you’re trusting an external party you have no control over. If jsdelivr.net gets compromised, or if a supposedly immutable version is silently mutated, your site can become an attack vector.

Subresource Integrity (SRI) solves this cleanly: every <link> or <script> tag carries an integrity="sha256-…" attribute that the browser verifies before executing the resource. If the hash doesn’t match, the browser blocks the load.

On arleo.eu, the LoveIt theme loads 9 jsdelivr URLs through its Hugo partials. None of them had an SRI hash. This post documents how I fixed that — and how I pushed the concept all the way to a fully automated pipeline.


LoveIt architecture and the CDN problem

LoveIt doesn’t hardcode its CDN URLs in templates. The architecture works like this:

themes/LoveIt/assets/data/cdn/jsdelivr.yml   ← lib list with versions
themes/LoveIt/layouts/_partials/init.html    ← loads YAML, builds $cdn
themes/LoveIt/layouts/_partials/plugin/script.html  ← emits <script> tags
themes/LoveIt/layouts/_partials/plugin/style.html   ← emits <link> tags

The jsdelivr.yml file looks like:

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
  # ...

The plugin/script.html and plugin/style.html partials emit tags without integrity=. These are what need to be overridden — without touching the theme’s git submodule.


Step 1 — Computing SRI hashes

For each jsdelivr URL found in public/*.html:

curl -fsSL https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js \
  | openssl dgst -sha256 -binary \
  | base64

All hashes are stored in a data/sri.yaml file local to the site (not in the theme):

"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="
# ...

Step 2 — Overriding LoveIt partials

Hugo resolves partials from the site’s layouts/ directory before the theme. We create two files that override the originals:

layouts/_partials/plugin/script.html (excerpt):

{{- $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 -}}
{{/* local resource — untouched */}}
{{- end -}}

The logic is straightforward: if the URL is present in data/sri.yaml, inject integrity= + crossorigin="anonymous". Otherwise, pass through unchanged. Local resources (Hugo fingerprinting) are not affected.

Same logic for plugin/style.html, propagating attributes to both generated <link> tags (preload + stylesheet).

After a hugo --minify, all CDN tags carry their hash:

<script src="https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js"
  crossorigin="anonymous"
  integrity="sha256-cBN+d7snO7LvlyuG6LBADMqL5TyyW/xFkRoYbcmGZd4=">

Step 3 — Weekly monitoring script

Having SRI hashes is good. Keeping them up to date without manual intervention is better. The script /home/jm/scripts/check-sri-versions.sh (cron Monday 8am) does three things:

3.1 Live hash verification

For each URL in data/sri.yaml, the script recomputes the hash live and compares:

live=$(curl -fsSL --max-time 30 "$url" \
  | openssl dgst -sha256 -binary | base64)
if [ "sha256-$live" != "$stored" ]; then
    # WARN — hash mismatch: CDN mutated or compromised
fi

A mismatch on a pinned version (e.g. mermaid@11.15.0) means jsdelivr has modified a theoretically immutable file — this is a critical security signal. This case is never auto-fixed: it creates a BetterStack incident for human review.

3.2 Outdated version detection

For each lib active in public/*.html, the script queries the jsdelivr API:

latest=$(curl -fsSL "https://data.jsdelivr.com/v1/packages/npm/$pkg" \
  | jq -r '.tags.latest')

Inactive libs (defined in jsdelivr.yml but not enabled in hugo.toml) are logged as INFO (inactive, skipped) without triggering alerts — avoiding noise from LoveIt’s optional features.

Three outcome categories:

SituationAction
Version up to dateOK
Minor/patch outdated (same major)Full auto-fix
Major bump (e.g. echarts 5→6)WARN — human incident

3.3 Minor/patch auto-fix

This is where it gets interesting. For a minor/patch outdated lib, the script:

  1. Backs up data/sri.yaml + assets/data/cdn/jsdelivr.yml
  2. Bumps the version in jsdelivr.yml (URL + comment line)
  3. Fetches + recomputes the new SRI hash
  4. Updates data/sri.yaml with the new URL and new hash
  5. hugo --minify --cleanDestinationDir
  6. bash deploy.sh (rsync to nginx)
  7. Cloudflare cache purge via API (purge_everything: true, arleo.eu zone)
  8. Live verification: re-checks all hashes against the jsdelivr CDN
  9. If OK — BetterStack heartbeat pinged, previous incident auto-resolved
  10. If failure at any step — rollback (restore backup + rebuild + redeploy + re-purge CF) + BetterStack incident
# Excerpt — Cloudflare purge
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}'

The CF token is reused from /etc/crowdsec/cf-sync.env (scope Zone:Cache Purge verified beforehand via /user/tokens/verify).


Step 4 — BetterStack integration

The script follows a complete alerting cycle:

WARN run
  POST /api/v2/incidents (summary + detailed description)
  Incident ID written to ~/.config/sri-check.open-incidents (chmod 600)

Next OK run
  POST /api/v2/incidents/{id}/resolve for each tracked ID
  ~/.config/sri-check.open-incidents deleted
  BetterStack heartbeat pinged

State file robustness:

  • 404 (incident manually deleted on BS side) — silently dropped
  • 5xx or network down — ID kept for retry on next run
  • Multiple parallel incidents — all resolved on next OK run

Files created/modified

FileRole
data/sri.yamlURL to sha256 hash map (SRI source of truth)
assets/data/cdn/jsdelivr.ymlLocal override of theme YAML (bumped versions)
layouts/_partials/plugin/script.htmlLoveIt override — injects integrity= on <script>
layouts/_partials/plugin/style.htmlLoveIt override — injects integrity= on <link>
/home/jm/scripts/check-sri-versions.shMonitoring + auto-fix script (cron Monday 8am)
/home/jm/.config/sri-check.envBS + CF secrets (chmod 600)
/home/jm/.config/sri-check.open-incidentsIncident state file (created/deleted on the fly)
/etc/logrotate.d/sri-checkWeekly rotation, 4 weeks, compress

No modifications inside themes/LoveIt/ (git submodule intact).


Adding new libs

Adding a line to data/sri.yaml is all it takes:

url="https://cdn.jsdelivr.net/npm/new-lib@1.2.3/dist/lib.min.js"
hash=$(curl -fsSL "$url" | openssl dgst -sha256 -binary | base64)
echo "\"$url\": \"sha256-$hash\"" >> data/sri.yaml

No template to touch. No manual rebuild — the next cron run handles it.


Result

  • 9 CDN libs protected by SRI, hashes automatically injected at every Hugo build
  • 0 modifications inside the LoveIt submodule
  • Fully automated pipeline: detection, bump, rebuild, deploy, CF purge, live verification, BS alerting, auto-resolution
  • ImmuniWeb/Qualys score: A+ maintained, SRI warnings gone

hugo-mcp sri-check plugin

The bash script has since been integrated as a native plugin of the hugo-mcp server. It exposes the check_sri_versions(auto_fix, dry_run) tool directly from Claude.ai, and delegates Cloudflare cache purge to the existing CF plugin rather than calling it directly from bash.

Source code and PR: github.com/jmrGrav/hugo-mcp — PR #1 feat/sri-check-plugin