---
title: "SRI on Hugo: automated hashes, auto-update and BetterStack alerting"
description: "How I implemented Subresource Integrity (SRI) on arleo.eu with Hugo and LoveIt, then automated CDN update detection, rebuild, Cloudflare cache purge and BetterStack alerting — with zero manual intervention."
url: "https://www.arleo.eu/en/posts/sri-cdn-hugo-automate/"
language: "en"
datePublished: "2026-05-17T00:00:00Z"
dateModified: "2026-05-17T00:00:00Z"
tags: ["security","hugo","cdn","cloudflare","monitoring","homelab","infrastructure"]
categories: ["security","homelab"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# SRI on Hugo: automated hashes, auto-update and BetterStack alerting
How I implemented Subresource Integrity (SRI) on arleo.eu with Hugo and LoveIt, then automated CDN update detection, rebuild, Cloudflare cache purge and BetterStack alerting — with zero manual intervention.



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

```yaml
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`:

```bash
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):

```yaml
"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):

```go-html-template
{{- $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:

```html
<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:

```bash
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:

```bash
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:

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

```bash
# 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

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

```bash
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](https://github.com/jmrGrav/hugo-mcp/pull/1)


## Tags

- security
- hugo
- cdn
- cloudflare
- monitoring
- homelab
- infrastructure

## Categories

- security
- homelab
