diff --git a/astro.config.mjs b/astro.config.mjs index 19737cf..b5e850b 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,6 +4,7 @@ import mdx from '@astrojs/mdx'; import sitemap from '@astrojs/sitemap'; import { defineConfig, fontProviders } from 'astro/config'; import { loadEnv } from 'vite'; +import avatarCacheSweep from './src/integrations/avatar-cache-sweep'; const envMode = process.env.NODE_ENV === 'production' ? 'production' : 'development'; const envVars = loadEnv(envMode, process.cwd(), ''); @@ -40,6 +41,7 @@ export default defineConfig({ locales: { de: 'de-DE', en: 'en-US' }, }, }), + avatarCacheSweep(), ], fonts: [ diff --git a/src/content/posts/de/security-header-astro-caddy.md b/src/content/posts/de/security-header-astro-caddy.md new file mode 100644 index 0000000..b25c359 --- /dev/null +++ b/src/content/posts/de/security-header-astro-caddy.md @@ -0,0 +1,183 @@ +--- +title: 'Security-Header für eine Astro-Seite hinter Caddy' +description: '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.' +pubDate: 'Apr 22 2026' +heroImage: '../../../assets/blog-placeholder-1.jpg' +category: de/technik +tags: + - security + - caddy + - astro + - csp + - privacy + - dsgvo +translationKey: security-headers-astro-caddy +--- + +Nachdem die Seite produktiv lief, habe ich sie durch ein paar Scanner laufen lassen: + +- **[Webbkoll](https://webbkoll.5july.net/de/check)** von der schwedischen Bürgerrechtsorganisation [Dataskydd.net](https://dataskydd.net/). +- **[securityheaders.com](https://securityheaders.com/)** für einen kompakten Grade-Report (A+ bis F). +- **[Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/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.de` ohne 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: + +```caddy +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: + +```bash +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: 0` sieht komisch aus, ist aber aktuelle OWASP-Empfehlung: der alte XSS-Auditor in älteren Browsern hat mehr Lücken eingeführt als geschlossen. Explizit `0` deaktiviert ihn. +- `X-Frame-Options: DENY` ist technisch redundant zu `frame-ancestors 'none'` in der CSP, aber für alte Browser ohne CSP-Level-2-Support ein zweiter Schutz. +- `-Server` entfernt den `Server`-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: + +```astro + +``` + +Astro hat es in der Produktion als `` direkt ins HTML gerendert. Meine strikte `script-src 'self'` blockiert das — zurecht. Drei Optionen standen zur Wahl: + +1. **`'unsafe-inline'` freigeben** — macht die CSP praktisch wertlos, fiel aus. +2. **SHA-256-Hash des Skripts in die CSP hängen** — muss bei jedem Build geprüft und ggf. aktualisiert werden, fragil. +3. **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`: + +```ts +function decode() { + document.querySelectorAll('[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: + +```astro + +``` + +**Lösung:** Nach dem Build landet das Skript als gehashte `_astro/...js`-Datei im Output, referenziert per ` +``` + +In production Astro inlined it as ``. My strict `script-src 'self'` blocked it — correctly. Three options: + +1. **Allow `'unsafe-inline'`** — makes the CSP practically worthless, out. +2. **Add a SHA-256 hash of the script to the CSP** — has to be verified after every build, fragile. +3. **Move the script into its own file and import it** — Astro always bundles module imports as external JS files, which `script-src 'self'` allows without issue. + +**Implementation:** Option 3. New file `src/scripts/decode-obf.ts`: + +```ts +function decode() { + document.querySelectorAll('[data-obf]').forEach((el) => { + el.textContent = atob(el.dataset.obf!); + el.removeAttribute('data-obf'); + }); +} +decode(); +document.addEventListener('astro:page-load', decode); +``` + +And in the contact page: + +```astro + +``` + +**Solution:** After the build the script lands in the output as a hashed `_astro/...js` file, referenced via `