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> tagsThe 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 \
| base64All 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
fiA 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:
| Situation | Action |
|---|---|
| Version up to date | OK |
| 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:
- Backs up
data/sri.yaml+assets/data/cdn/jsdelivr.yml - Bumps the version in
jsdelivr.yml(URL + comment line) - Fetches + recomputes the new SRI hash
- Updates
data/sri.yamlwith the new URL and new hash hugo --minify --cleanDestinationDirbash deploy.sh(rsync to nginx)- Cloudflare cache purge via API (
purge_everything: true, arleo.eu zone) - Live verification: re-checks all hashes against the jsdelivr CDN
- If OK — BetterStack heartbeat pinged, previous incident auto-resolved
- 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 pingedState file robustness:
404(incident manually deleted on BS side) — silently dropped5xxor network down — ID kept for retry on next run- Multiple parallel incidents — all resolved on next OK run
Files created/modified
| File | Role |
|---|---|
data/sri.yaml | URL to sha256 hash map (SRI source of truth) |
assets/data/cdn/jsdelivr.yml | Local override of theme YAML (bumped versions) |
layouts/_partials/plugin/script.html | LoveIt override — injects integrity= on <script> |
layouts/_partials/plugin/style.html | LoveIt override — injects integrity= on <link> |
/home/jm/scripts/check-sri-versions.sh | Monitoring + auto-fix script (cron Monday 8am) |
/home/jm/.config/sri-check.env | BS + CF secrets (chmod 600) |
/home/jm/.config/sri-check.open-incidents | Incident state file (created/deleted on the fly) |
/etc/logrotate.d/sri-check | Weekly 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.yamlNo 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