---
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'
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
<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:

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<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:

```astro
<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:

```caddy
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://webbkoll.5july.net/de/check):

- **HTTPS als Voreinstellung**: ✓
- **Content Security Policy**: ✓ "Gute Richtlinie"
- **Referrer Policy**: ✓ "Referrer werden nicht übermittelt"
- **Cookies**: 0
- **Drittanbieter-Requests**: 0

Bei [securityheaders.com](https://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](/webmention-avatare-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-referrer` ist 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](https://webbkoll.5july.net/de/check) liefert dir pro Befund gleich den passenden DSGVO-Artikel mit; [securityheaders.com](https://securityheaders.com/) ist der schnelle Sanity-Check; [Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/observatory) geht bei der CSP am tiefsten.
