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 `