Astro-Client-Islands unter strikter CSP — die Hydrations-Inlines per sha256-Hash erlauben
Warum react-Islands mit `client:load` unter `script-src 'self'` still scheitern, wie ich die zwei deterministischen Astro-Hydrations-Inlines pro Hash freigegeben habe — und wie man die Hashes nach Astro-Updates nachzieht.
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
'unsafe-inline'für Skripte zulassen — macht die ganze CSP nutzlos.- 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.
- 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:
- Forgejo-Lauf rot, Annotation „CSP hash drift detected” mit beiden Hash-Sets nebeneinander.
ssh hetzner, den Patch aus dem Log copy-pasten —sed-Ersatz,caddy validate,systemctl reload caddy.- 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-inlinefürscript-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.