Security-Header für eine Astro-Seite hinter Caddy
Wie ich meine Seite mit einer strikten Content Security Policy, sauberen Response-Headern und einer DSGVO-konformen Konfiguration gehärtet habe — und den Astro-Inline-Script-Stolperstein dabei gelöst.
Kategorie: Technik
Nachdem die Seite produktiv lief, habe ich sie durch ein paar Scanner laufen lassen:
- Webbkoll von der schwedischen Bürgerrechtsorganisation Dataskydd.net.
- securityheaders.com für einen kompakten Grade-Report (A+ bis F).
- Mozilla HTTP Observatory für einen tiefergehenden CSP-Check.
Das Ergebnis war gemischt: null Cookies, null Drittanbieter-Requests, HTTPS als Default — aber keine Content Security Policy, kein HSTS, keine Referrer-Policy. Das Fundament ist gut, der Helm fehlte.
Dieser Beitrag ist chronologisch in Problem → Umsetzung → Lösung aufgebaut: pro Abschnitt beschreibe ich, was der Scan fand, wie ich es angegangen bin, und was am Ende rauskam.
Warum gerade Webbkoll
Webbkoll ist kein generischer Security-Scanner. Die Seite erklärt zu jedem geprüften Punkt, welcher Artikel der DSGVO dahintersteht:
- Art. 5.1.c (Datenminimierung) — rechtfertigt die Forderung nach einer restriktiven Referrer-Policy und dem Verzicht auf Drittanbieter-Requests.
- Art. 25 (Privacy by Design / by Default) — der komplette Gedanke hinter “null Cookies, null Third-Party-Calls ohne Einwilligung”.
- Art. 32 (Sicherheit der Verarbeitung) — deckt die klassischen Security-Header ab: HSTS, CSP, X-Content-Type-Options, X-Frame-Options.
Das macht Webbkoll zum Scanner meiner Wahl, wenn es nicht nur darum geht, “sicher” zu sein, sondern belegen zu können, dass die Seite DSGVO-konform gebaut ist. Ein Grade von securityheaders.com ist ein hübscher Marketing-Badge; ein grüner Webbkoll-Scan ist ein Indiz für einen sauberen Artikel-25-Nachweis.
Warum das überhaupt etwas bringt
Eine statische Seite ohne Login, ohne Formulare, ohne User-Input wirkt auf den ersten Blick wie ein uninteressantes Angriffsziel. Die relevanten Schutzmaßnahmen lassen sich trotzdem klar benennen:
- Content Security Policy ist die Versicherung gegen den Tag, an dem doch mal fremder HTML-Code auf der Seite landet — durch eine Supply-Chain-Kompromittierung einer Dependency, eine Fehlkonfiguration, einen Tippfehler in einem Template. Ohne CSP führt der Browser beliebiges eingeschleustes JavaScript aus.
- HSTS schützt Leser vor Downgrade-Angriffen. Wer im offenen WLAN
adrian-altner.deohne Protokoll in die Adressleiste tippt, würde ohne HSTS zuerst über HTTP anfragen und wäre in dem Moment angreifbar. - Referrer-Policy ist reiner Datenschutz für meine Leser. Ohne sie übermittelt ihr Browser beim Klick auf einen externen Link standardmäßig die URL, auf der sie gerade waren. Das kann hochsensibel sein.
Das Setup
- VPS bei Hetzner, Debian.
- Caddy als Reverse-Proxy vor einem Container (nginx serviert statische Astro-Builds auf
127.0.0.1:4321). - Astro 6 als statischer Site-Generator, deutsch/englisch lokalisiert.
Security-Header im Caddyfile
Problem: Caddy liefert out-of-the-box nur die absolut nötigen Header aus — kein CSP, kein HSTS, keine Referrer-Policy. Der Browser arbeitet also mit seinen laxesten Defaults.
Umsetzung: Alle relevanten Direktiven kommen in einen header-Block in der Site-Config:
adrian-altner.de, www.adrian-altner.de {
encode zstd gzip
@www host www.adrian-altner.de
redir @www https://adrian-altner.de{uri} permanent
header /fonts/* Cache-Control "public, max-age=31536000, immutable"
header /_astro/* Cache-Control "public, max-age=31536000, immutable"
header /images/* Cache-Control "public, max-age=604800"
header {
Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
Referrer-Policy "no-referrer"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "0"
Permissions-Policy "geolocation=(), microphone=(), camera=(), interest-cohort=()"
Cross-Origin-Opener-Policy "same-origin"
-Server
}
reverse_proxy 127.0.0.1:4321
}
Validieren und neu laden, ohne Caddy wirklich neu zu starten:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Lösung: Ein curl -I auf die Seite zeigt jetzt den vollständigen Header-Satz. Ein paar Begründungen zu den weniger offensichtlichen Zeilen:
default-src 'none'ist strenger als'self'. Es zwingt dazu, jede Direktive explizit zu whitelisten — nichts fällt mehr stillschweigend auf einen laxeren Default zurück.X-XSS-Protection: 0sieht komisch aus, ist aber aktuelle OWASP-Empfehlung: der alte XSS-Auditor in älteren Browsern hat mehr Lücken eingeführt als geschlossen. Explizit0deaktiviert ihn.X-Frame-Options: DENYist technisch redundant zuframe-ancestors 'none'in der CSP, aber für alte Browser ohne CSP-Level-2-Support ein zweiter Schutz.-Serverentfernt denServer-Header und damit einen kleinen Fingerprinting-Vektor.interest-cohort=()in der Permissions-Policy lehnt Googles FLoC ausdrücklich ab, auch wenn das Feature praktisch tot ist.
Die CSP und der Astro-Inline-Script-Stolperstein
Problem: Der erste Recheck zeigte einen frischen Fehler: /kontakt/ funktionierte nicht mehr. Auf der Kontakt-Seite wird die E-Mail-Adresse obfuskiert (als base64 im data-obf-Attribut) und clientseitig decodiert. Das decode-Skript lag inline in der .astro-Datei:
<script>
function decode() {
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
el.textContent = atob(el.dataset.obf!);
el.removeAttribute('data-obf');
});
}
decode();
document.addEventListener('astro:page-load', decode);
</script>
Astro hat es in der Produktion als <script type="module">...</script> direkt ins HTML gerendert. Meine strikte script-src 'self' blockiert das — zurecht. Drei Optionen standen zur Wahl:
'unsafe-inline'freigeben — macht die CSP praktisch wertlos, fiel aus.- SHA-256-Hash des Skripts in die CSP hängen — muss bei jedem Build geprüft und ggf. aktualisiert werden, fragil.
- Skript in eine eigene Datei auslagern und importieren — Astro bündelt Module-Imports immer als externe JS-Datei, die
script-src 'self'problemlos erlaubt.
Umsetzung: Option 3. Neue Datei src/scripts/decode-obf.ts:
function decode() {
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
el.textContent = atob(el.dataset.obf!);
el.removeAttribute('data-obf');
});
}
decode();
document.addEventListener('astro:page-load', decode);
Und in der Kontakt-Seite:
<script>
import '~/scripts/decode-obf';
</script>
Lösung: Nach dem Build landet das Skript als gehashte _astro/...js-Datei im Output, referenziert per <script type="module" src="..."> — CSP-konform und mit korrektem Cache-Control-Header.
Was mit style-src 'unsafe-inline' ist
Das bleibt. Astro rendert pro Komponente scoped <style>-Tags inline, dazu die @font-face-Deklaration und View-Transition-Keyframes. Ohne SSR-Middleware, die pro Request einen Nonce injiziert, lässt sich das bei einer statischen Seite nicht sauber lösen. Der Tradeoff ist vertretbar: Style-Injection erlaubt keine Code-Ausführung — sie ist deutlich weniger gefährlich als Script-Injection. Die meisten Astro-Sites bleiben an genau diesem Punkt hängen.
Der Referrer-Header
Problem: Meine erste Version setzte Referrer-Policy: strict-origin-when-cross-origin — ein sinnvoller Default, der nur die Origin (nicht die vollständige URL) weitergibt. Webbkoll bewertet das trotzdem als gelben Hinweis: bei HTTPS-zu-HTTPS-Requests wird die Origin übermittelt.
Umsetzung: Da die Seite ohnehin keine Analytics hat und niemand den Referrer braucht, habe ich auf no-referrer umgestellt:
Referrer-Policy "no-referrer"
Lösung: Browser schicken bei Klicks auf externe Links (Mastodon, GitHub, Webmention-Profile) gar keinen Referer-Header mehr. Strengstmögliche Einstellung, keine Nachteile für eine Content-Site — und ein sauberer Art.-5.1.c-Nachweis (Datenminimierung).
Das Ergebnis
Der finale Scan bei Webbkoll:
- HTTPS als Voreinstellung: ✓
- Content Security Policy: ✓ “Gute Richtlinie”
- Referrer Policy: ✓ “Referrer werden nicht übermittelt”
- Cookies: 0
- Drittanbieter-Requests: 0
Bei securityheaders.com gibt’s ein A+. Der einzige rot markierte CSP-Check ist style-src 'unsafe-inline' — der Astro-Trade-off, den ich oben beschrieben habe.
Was noch offen war
Ein Scanner-Befund blieb ungelöst: externe Avatar-Bilder aus Webmentions wurden von img-src 'self' data: blockiert. Der Fix hat ein eigenes kleines Unterkapitel verdient, weil er nicht nur ein CSP-Problem löst, sondern die Leser auch vor Drittanbieter-Requests schützt — Webmention-Avatare zur Build-Zeit lokal cachen.
Was du dir mitnimmst
- Security-Header in Caddy sind vier Zeilen. Es gibt keinen guten Grund, sie nicht zu setzen.
default-src 'none'statt'self'zwingt dich, jede Direktive bewusst zu whitelisten. Du lernst dabei, was deine Seite eigentlich lädt.- Inline-Scripts vermeiden. Sobald CSP strikt wird, musst du sie sowieso auslagern — Astro bündelt Module-Imports automatisch als externe Datei.
no-referrerist für Content-Sites eine vernünftige Default-Einstellung. Wer keine Analytics oder Conversion-Tracking betreibt, verliert dadurch nichts — und minimiert Daten im Sinne der DSGVO.- Scanner sind kein Luxus, sondern Nachweis-Werkzeuge. Webbkoll liefert dir pro Befund gleich den passenden DSGVO-Artikel mit; securityheaders.com ist der schnelle Sanity-Check; Mozilla HTTP Observatory geht bei der CSP am tiefsten.