← Beiträge

Astro-Client-Islands unter strikter CSP — die Hydrations-Inlines per sha256-Hash erlauben


Die Foto-Sektion dieser Seite hat einen Lightbox-Karussell als React-Komponente mit client:load. Lokal funktionierte alles, in Produktion klickte man auf ein Bild — und nichts passierte. Ein Blick in die Browser-Konsole:

Executing inline script violates the following Content Security Policy directive 'script-src 'self''.
Either the 'unsafe-inline' keyword, a hash ('sha256-QzWFZi+FLIx23tnm9SBU4aEgx4x8DsuASP07mfqol/c='),
or a nonce ('nonce-...') is required to enable inline execution.

Zwei dieser Fehler, mit zwei unterschiedlichen Hashes. Die strikte CSP dieser Seite hat genau das gemacht, wofür sie gebaut ist — und genau das blockiert, was Astro für Hydration braucht.

Was Astro inline einfügt — und warum

Astros client:*-Direktiven (client:load, client:visible, client:idle, …) brauchen einen winzigen Bootstrap, der entscheidet wann eine Komponente im Browser hydratisiert wird und ihn dann ausführt. Diesen Bootstrap injiziert Astro als zwei kleine Inline-<script>-Blöcke in jede Seite mit Islands:

<script>(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).load=e;w...</script>
<script>(()=>{var A=Object.defineProperty;var g=(i,o,a)=>o in i?A(i,o,{enumerable:!0,con...</script>

Zusammen rund 1,5 kB. Bei script-src 'self' ohne 'unsafe-inline' blockt der Browser beide. Folge: das Hydrations-System startet nie, jeder Island bleibt static HTML, alle Event-Handler aus React/motion/etc. fehlen.

Der wichtige Punkt: diese zwei Blöcke sind deterministisch je Astro-Version. Das genau gleiche npm run build produziert die genau gleichen Bytes — und damit die genau gleichen sha256-Hashes. Astro generiert keine zufälligen IDs, keine Timestamps, keine Build-Hashes da drin.

Drei Auswege

  1. 'unsafe-inline' für Skripte zulassen — macht die ganze CSP nutzlos.
  2. Nonces. Funktionieren nur, wenn Caddy pro Request einen frischen Nonce in HTML und Header injiziert. Das ist Infrastruktur, die ich auf dem Static-Setup nicht haben will.
  3. Hash-Pinning: die exakten sha256-Hashes der zwei Inlines in die CSP eintragen.

Hash-Pinning passt zu einem Static-Site-Stack: keine Server-Logik, keine Request-spezifische Verarbeitung, einfach zwei zusätzliche Tokens in der script-src-Direktive.

Hashes berechnen

Direkt aus dem Build-Output, kein externes Tool nötig:

python3 -c "
import re, hashlib, base64
from pathlib import Path
html = Path('dist/fotos/unterwegs/asien/malaysia/kuala-lumpur/index.html').read_text()
for body in dict.fromkeys(re.findall(r'<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>', html, re.DOTALL)):
    if body.strip():
        print('sha256-' + base64.b64encode(hashlib.sha256(body.encode()).digest()).decode())
"

Jeder Pfad mit einem client:*-Island reicht — die zwei Inlines sind seitenübergreifend identisch. Das Skript spuckt die zwei Hashes aus, die in die CSP gehören.

Caddy-CSP patchen

In meiner Caddy-Config hängt die CSP als einziger Wert in einem header-Block. Patch:

- script-src 'self';
+ script-src 'self' 'sha256-QzWFZi+FLIx23tnm9SBU4aEgx4x8DsuASP07mfqol/c=' 'sha256-QJZDUlo/qa5AJCrG6vHyWcatjwCeWidEHQfJc601lzw=';

Der Rest der CSP bleibt unverändert — default-src 'none', img-src, style-src, alles wie zuvor. Reload:

sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy

curl -sI https://adrian-altner.de/ | grep -i content-security zeigt direkt danach die neuen Hashes im Header. Beim nächsten Seitenaufruf hydratisiert der Island, das Modal öffnet sich.

Was bei Astro-Updates passiert

Die Hashes hängen am Inhalt der Inlines. Astro kann den Bootstrap zwischen Versionen umbauen — gerade Major- und manchmal Minor-Releases. Patch- und Minor-Updates innerhalb einer Major lassen ihn meist in Ruhe, aber „meist” ist nicht „immer”. Ohne Drift-Check merkst du es erst, wenn dir eine Komponente in Prod stillschweigend kaputtgeht.

Drift-Check im Deploy-Workflow

Der einzig sinnvolle Moment für die Prüfung ist direkt nach dem Deploy: das neue Build-Artefakt läuft, die alte CSP steht noch. Mein Forgejo-Runner hängt deshalb am Workflow einen zusätzlichen Step:

- name: Verify CSP script-src hashes
  run: |
    SAMPLE_PATH='/fotos/unterwegs/asien/malaysia/kuala-lumpur/'
    export LIVE_HTML="$(curl -fsSL "https://adrian-altner.de${SAMPLE_PATH}")"
    export LIVE_CSP="$(curl -fsSI "https://adrian-altner.de${SAMPLE_PATH}" \
      | tr -d '\r' | grep -i '^content-security-policy:')"

    python3 - <<'PY'
    import os, re, hashlib, base64, sys
    html = os.environ['LIVE_HTML']
    csp  = os.environ['LIVE_CSP']
    inlines = re.findall(r'<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>', html, re.DOTALL)
    served = sorted({'sha256-' + base64.b64encode(hashlib.sha256(b.encode()).digest()).decode()
                     for b in inlines if b.strip()})
    configured = sorted(set(re.findall(r"sha256-[A-Za-z0-9+/=]+", csp)))
    if set(served) == set(configured):
        print(f"OK: CSP hashes match ({len(served)}).")
        sys.exit(0)
    print("::error title=CSP hash drift::live HTML and CSP no longer agree")
    # ... print missing/stale + ready-to-paste sed patch ...
    sys.exit(1)
    PY

Wichtig: weder Build-Verzeichnis lesen noch sudo für die Caddy-Datei. Der Runner curlt nur den Public-Endpoint, parst die zwei Inlines aus dem HTML und vergleicht mit dem Content-Security-Policy-Response-Header. Spiegelt 1:1 was der Browser sieht und braucht keinerlei privilegierten Zugriff — die Forgejo-sudoers-Regel des Runners kann eng auf podman build und systemctl restart beschränkt bleiben.

Bei Drift schlägt der Step fehl mit einer Forgejo-Annotation und einem fertigen sudo sed-Patch im Log. Workflow:

  1. Forgejo-Lauf rot, Annotation „CSP hash drift detected” mit beiden Hash-Sets nebeneinander.
  2. ssh hetzner, den Patch aus dem Log copy-pasten — sed-Ersatz, caddy validate, systemctl reload caddy.
  3. Im Forgejo-UI „Re-run workflow” — der Step läuft grün.

Bei jedem Push und einmal täglich um 06:00 UTC läuft der Deploy-Workflow ohnehin durch, das macht aus dem Drift-Check eine kostenlose Daily-Versicherung.

Was ich bewusst nicht gemacht habe

  • Lightbox als Vanilla <dialog> neu schreiben. Wäre die Alternative gewesen — nativen <dialog>-Element + ein paar Vanilla-TS-Handler, kein React-Island, keine Astro-Hydrations-Inlines, keine CSP-Pflege. Inhaltlich aber ein größerer Refactor und Verlust von motion für die Slide-/Drag-Animationen. Hash-Pinning ist die kleinere Änderung mit nur einem winzigen Wartungs-Anhang.
  • unsafe-inline für script-src öffnen. Wenn man einmal 'unsafe-inline' reinlässt, kann man die CSP für Skripte praktisch löschen — der Sinn der ganzen Übung war, gegen XSS und Supply-Chain-Compromise eine harte Schranke zu haben.

Der Status quo: zwei Hash-Tokens in der CSP, ein Forgejo-Step der nach jedem Deploy gegen den Live-Header vergleicht und bei Drift rot wird, ansonsten unangetastet.

← Beiträge