In Teil 1 habe ich einen kleinen VPS gemietet und abgesichert. Jetzt soll er endlich etwas tun: eine Website ausliefern. In diesem Teil geht es um DNS, den Webserver Caddy, automatische TLS-Zertifikate und Security-Header. Und um eine Content-Security-Policy, die mir als Erstes das Theme meiner Seite zerlegt hat.

Voraussetzungen

  • Der gehärtete VPS aus Teil 1 mit Debian 13, Firewall ufw (Ports 22, 80 und 443 offen)
  • Eine eigene Domain, deren DNS bei einem Anbieter liegt, bei dem man A- und AAAA-Einträge setzen kann (bei mir: Cloudflare)
  • Später für den CSP-Teil: Hugo 0.169.0 mit dem Theme PaperMod

In den Beispielen steht example.com für meine Domain, 203.0.113.10 für die IPv4- und 2001:db8::10 für die IPv6-Adresse des Servers.

Schritt 1: DNS – und warum die Wolke grau bleibt

Meine Domain liegt bei Cloudflare. Cloudflare kann Einträge entweder nur als DNS beantworten (graue Wolke, „DNS only“) oder den ganzen Verkehr als Proxy durch die eigenen Server leiten (orange Wolke). Der Proxy ist standardmäßig an. Ich habe ihn bewusst ausgeschaltet:

  • Datenschutz: Mit Proxy läuft jeder Seitenaufruf inklusive IP-Adresse über ein US-Unternehmen, das den TLS-Verkehr zudem entschlüsselt. Das müsste ich in der Datenschutzerklärung erklären – und genau solche Abhängigkeiten wollte ich vermeiden.
  • Durchschaubarkeit: Ohne Proxy holt sich der Webserver seine Zertifikate selbst, und ich sehe direkt, was zwischen Browser und Server passiert.
  • Zukunft: Ich plane einen eigenen Fediverse-Server. Fediverse-Server sprechen direkt miteinander, und Bot-Schutz im Proxy blockt solche Anfragen gern.

Cloudflare beantwortet bei mir also nur noch DNS-Anfragen und sieht keine Besucher.

IPv6-Adresse herausfinden

Für den AAAA-Eintrag brauche ich die IPv6-Adresse des Servers:

ip -6 addr show scope global

Interessant: Die Adresse, die netcup automatisch vergibt, wird aus der MAC-Adresse der virtuellen Netzwerkkarte gebildet (man erkennt das am ff:fe in der Mitte). Sie bleibt stabil, solange die Netzwerkkarte nicht neu angelegt wird. Für den Anfang reicht das völlig.

Die Einträge

TypNameZielProxy
A@203.0.113.10DNS only
AAAA@2001:db8::10DNS only
Awww203.0.113.10DNS only
AAAAwww2001:db8::10DNS only

Prüfen, ob alles angekommen ist – direkt beim öffentlichen Resolver gefragt:

dig +short example.com A @1.1.1.1
dig +short example.com AAAA @1.1.1.1
dig +short www.example.com A @1.1.1.1

Kommen hier Adressen von Cloudflare zurück statt der eigenen, ist der Proxy noch an.

Schritt 2: Caddy installieren

Als Webserver nutze ich Caddy. Der große Vorteil: Caddy besorgt und erneuert TLS-Zertifikate von Let’s Encrypt komplett automatisch, die Konfiguration ist kurz und lesbar, und Caddy ist – natürlich – Open Source.

Debian hat zwar ein eigenes Caddy-Paket, das ist aber oft deutlich älter. Ich nehme deshalb das offizielle Repository des Projekts, dann kommen Updates ganz normal über apt:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
caddy version

Für den ersten Test lege ich ein Webverzeichnis mit einer Mini-Seite an:

sudo mkdir -p /var/www/kabelbruch
echo '<!doctype html><html lang="de"><meta charset="utf-8"><title>Kabelbruch</title><h1>Kabelbruch – hier entsteht was.</h1></html>' | sudo tee /var/www/kabelbruch/index.html

Schritt 3: Das Caddyfile

Die komplette Konfiguration steht in /etc/caddy/Caddyfile:

{
    email admin@example.com
}

www.example.com {
    header -Server
    redir https://example.com{uri} permanent
}

example.com {
    root * /var/www/kabelbruch
    encode zstd gzip
    file_server

    header {
        Strict-Transport-Security "max-age=31536000"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
        -Server
    }
}

Was hier passiert:

  • email ist die Adresse für Let’s Encrypt. Dorthin kommt nur Post, wenn mit den Zertifikaten etwas nicht stimmt.
  • www leitet dauerhaft (301) auf die Domain ohne www um. So gibt es die Seite nur unter einer Adresse.
  • encode zstd gzip komprimiert die Antworten.
  • file_server liefert die Dateien aus /var/www/kabelbruch aus. Mehr braucht eine statische Seite nicht.

Und die Header im Einzelnen:

HeaderWirkung
Strict-Transport-Security (HSTS)Browser verbinden sich ein Jahr lang nur noch per HTTPS. Bewusst ohne preload – das lässt sich kaum rückgängig machen, also erst mal ohne.
X-Content-Type-OptionsDer Browser rät keine Dateitypen, sondern nimmt, was der Server sagt.
Referrer-PolicyAndere Seiten erfahren beim Klick auf einen Link nur meine Domain, nicht die genaue Unterseite.
Permissions-PolicyKamera, Mikrofon und Standort sind für diese Seite grundsätzlich gesperrt.
Content-Security-Policy (CSP)Die Seite darf nur Inhalte von sich selbst laden. Keine fremden Skripte, Schriftarten oder Tracker – und wenn das Theme doch etwas Externes nachladen will, fällt es sofort auf.
-ServerDer Server verrät nicht, welche Software läuft.

Noch ein angenehmer Standard: Caddy schreibt von sich aus keine Zugriffsprotokolle. Für den Datenschutz ist das genau richtig.

Bevor eine neue Konfiguration aktiv wird, lasse ich sie immer erst prüfen:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Mein erstes Caddyfile hatte ich aus einem Chatfenster kopiert. Beim Nachsehen stand in Zeile 5 plötzlich so etwas:

[www.example.com](https://www.example.com) {

Der Chat hatte den Hostnamen automatisch in einen Markdown-Link verwandelt – und genau so war er in der Datei gelandet. caddy validate hätte das zum Glück abgefangen, bevor etwas kaputtgeht.

Der eigentliche Spaß kam beim Reparieren: Jeder sed-Befehl, in dem der Hostname vorkam, wurde beim Kopieren wieder zu einem Link. Ich habe die Klammer also mehrfach „repariert“, indem ich sie neu hineinkopiert habe. Gelöst hat es ein Befehl, der den Hostnamen gar nicht erst enthält, sondern nur die überflüssigen Zeichen in Zeile 5 entfernt:

sudo sed -i -e '5s/^\[//' -e '5s/](.*)//' /etc/caddy/Caddyfile

Und dann die nächste Falle: Als ich die Ausgabe von grep zur Kontrolle zurück in den Chat kopiert habe, sah sie wieder kaputt aus – weil der Chat den Hostnamen beim Anzeigen erneut verlinkt hat. Die Datei war längst in Ordnung. Ein Screenshot vom Terminal hat das am Ende geklärt. Ein Test, der den Hostnamen nicht enthält, hätte es auch getan:

grep -c '\[' /etc/caddy/Caddyfile    # 0 = keine eckigen Klammern mehr

Learning: Hostnamen in Befehlen, die man aus Chats oder Webseiten kopiert, immer kritisch anschauen. Und im Zweifel einen Screenshot vom Terminal machen statt Text zu kopieren.

Schritt 4: Testen

curl -I https://example.com
curl -I https://www.example.com

Die erste Anfrage liefert HTTP/2 200 mit allen Headern, die zweite eine 301-Weiterleitung. Ein gültiges Zertifikat gab es nach wenigen Sekunden, ohne dass ich irgendetwas dafür tun musste.

Dabei sind mir noch zwei Kleinigkeiten aufgefallen:

1. server: Caddy bei der Weiterleitung. Die Header-Regeln galten anfangs nur für den Hauptblock, nicht für den www-Block. Deshalb steht oben im Caddyfile auch dort ein header -Server.

2. HTTP/3 braucht UDP. In den Antworten steht alt-svc: h3=":443". Caddy bietet also HTTP/3 an, und das läuft über UDP Port 443. Die Firewall aus Teil 1 lässt aber nur TCP durch. Browser fallen dann still auf HTTP/2 zurück, es funktioniert also trotzdem – sauberer ist es aber, UDP auch freizugeben:

sudo ufw allow 443/udp

Und falls caddy validate meckert, die Datei sei „not formatted“: Das ist reine Kosmetik (Leerzeichen statt Tabs). sudo caddy fmt --overwrite /etc/caddy/Caddyfile räumt das auf.

Schritt 5: Mond und Sonne – die CSP schlägt zu

Ein paar Tage später – nun ja, ein paar Stunden – lief die echte Seite: gebaut mit Hugo und dem Theme PaperMod (wie sie dorthin kommt, zeige ich in Teil 3). Und oben neben dem Titel sah ich Mond und Sonne gleichzeitig. Eigentlich zeigt PaperMod nur eines der beiden Symbole, je nachdem, ob gerade das helle oder das dunkle Design aktiv ist. Umschalten ging auch nicht.

Die Browser-Konsole (F12) hat den Grund sofort verraten, viermal in Rot:

Executing inline script violates the following Content Security Policy
directive 'default-src 'self''. Either the 'unsafe-inline' keyword,
a hash ('sha256-…'), or a nonce ('nonce-…') is required to enable
inline execution.

PaperMod hat ein paar kleine Skripte direkt im HTML stehen („Inline-Skripte“), unter anderem für den Hell-Dunkel-Umschalter. Meine strenge CSP erlaubt aber nur Skripte aus eigenen Dateien. Lokal mit hugo server merkt man davon nichts, weil dort keine CSP gesetzt ist – erst auf dem echten Server.

Die bequeme Lösung – und warum ich sie nicht nehme

Am einfachsten wäre script-src 'self' 'unsafe-inline'. Damit wäre aber jedes Inline-Skript erlaubt, also auch eines, das jemand irgendwann einschleust. Das würde einen großen Teil des Schutzes der CSP aushebeln.

Die saubere Lösung: Hashes

Stattdessen erlaube ich genau diese Skripte über ihren SHA-256-Hash. Der Browser berechnet den Hash jedes Inline-Skripts und führt es nur aus, wenn er in der CSP steht. Ein verändertes oder fremdes Skript hat einen anderen Hash und wird weiter blockiert.

Die Hashes stehen zwar in der Fehlermeldung, aber abtippen wollte ich sie nicht: l und I, 0 und O – ein einziges falsches Zeichen, und der Hash ist wertlos. Also lasse ich sie aus einem lokalen Build berechnen. Das Ergebnis ist identisch, solange lokal und auf dem Server dieselbe Hugo-Version baut:

hugo --minify -D -d /tmp/kb-csp

python3 - <<'EOF'
import re, hashlib, base64, pathlib
hashes = set()
pattern = re.compile(r'<script(?![^>]*\bsrc=)(?![^>]*application/ld\+json)[^>]*>(.*?)</script>', re.S | re.I)
for f in pathlib.Path('/tmp/kb-csp').rglob('*.html'):
    for m in pattern.finditer(f.read_text(encoding='utf-8')):
        digest = hashlib.sha256(m.group(1).encode('utf-8')).digest()
        hashes.add("'sha256-" + base64.b64encode(digest).decode() + "'")
print(" ".join(sorted(hashes)))
EOF
  • -D baut auch Entwürfe mit, damit auch Skripte erfasst werden, die nur auf Artikelseiten vorkommen.
  • JSON-LD-Blöcke (application/ld+json) lasse ich aus. Das sind Daten für Suchmaschinen, die der Browser nicht ausführt.

Heraus kamen fünf Hashes, darunter die vier aus der Konsole. Die kommen in die CSP:

Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-<HASH_1>' 'sha256-<HASH_2>' 'sha256-<HASH_3>' 'sha256-<HASH_4>' 'sha256-<HASH_5>'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"

Vorher sichere ich das Caddyfile, dann validate und reload:

sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak
# CSP-Zeile anpassen, dann:
sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy

Seite neu geladen (Strg+Shift+R) – und schon war nur noch ein Symbol zu sehen, der Umschalter funktionierte, und die Konsole war leer.

Der Haken: Ändert sich durch ein Update von PaperMod oder Hugo eines dieser Skripte, stimmt sein Hash nicht mehr. Das merkt man an genau demselben Symptom: Mond und Sonne gleichzeitig. Dann einfach das Skript oben noch einmal laufen lassen und die CSP aktualisieren. Das automatisch in der Pipeline zu prüfen, steht auf meiner Liste.

Stolperfallen & Learnings

  • Cloudflare-Proxy bewusst entscheiden. Er ist standardmäßig an und hat Folgen für Datenschutz und direkte Server-zu-Server-Verbindungen.
  • caddy validate vor jedem reload. Das hat mich vor dem Link im Caddyfile bewahrt.
  • Kopierte Hostnamen prüfen. Chats und Webseiten verwandeln sie gern in Links – auch beim Zurückkopieren der Ausgabe.
  • Header gelten pro Block. Der www-Block braucht sein eigenes header -Server.
  • HTTP/3 = UDP 443. In der Firewall freigeben.
  • CSP mit Hashes statt unsafe-inline. Die Hashes berechnen statt abtippen, und nach Theme-Updates neu prüfen.

Fazit & Ausblick

Mit einer Handvoll Zeilen Caddyfile liefert der Server die Seite jetzt per HTTPS aus, mit automatisch erneuerten Zertifikaten, ohne Zugriffsprotokolle und mit Security-Headern, die fremde Inhalte konsequent draußen halten. Die CSP hat mich zwar kurz das Theme gekostet, aber genau dafür ist sie da: Sie hat sofort gezeigt, was auf der Seite eigentlich alles ausgeführt wird.

Was als Nächstes kommt: In Teil 3 baue ich die Pipeline. Ich schreibe Artikel auf meinem Laptop, pushe sie in meinen eigenen Git-Server im Homelab, ein Runner baut die Seite und schiebt sie zum VPS – über einen SSH-Schlüssel, der dort nichts anderes darf, als genau dieses eine Verzeichnis zu beschreiben.