| Version 3 (modified by , 12 days ago) (diff) |
|---|
Security-Header in der NGINX-Konfiguration
Das Problem
add_header in NGINX vererbt nicht so, wie man es erwartet. Die Direktiven
werden von der uebergeordneten Ebene nur dann geerbt, wenn auf der
aktuellen Ebene kein einziges add_header steht.
Ein beliebiges add_header in einem location-Block loescht damit
saemtliche Header aus dem server-Block -- stillschweigend, ohne Warnung
beim Konfigurationstest. Genau das ist der Grund, warum der
Strict-Transport-Security-Header bei Nextcloud fehlte: die empfohlene
Nextcloud-Konfiguration setzt in den PHP- und Asset-Locations eigene Header.
Ab NGINX 1.29.3 laesst sich das mit add_header_inherit merge; abstellen.
Unsere Version ist aelter, deshalb die Loesung ueber wiederholte Includes.
Vorsicht bei einer spaeteren Umstellung auf merge: die Direktive
haengt die Werte der uebergeordneten Ebene an, sie ersetzt sie nicht. Bei
gleichnamigen Headern entstehen Duplikate. Der Fehlermodus wechselt damit
von "Header fehlt" zu "Header doppelt" -- bei X-Frame-Options verwerfen
mehrere Browser den Header dann komplett, bei CSP wird die Schnittmenge
beider Policies durchgesetzt.
Aufbau
Drei Ebenen, per Komposition statt per Kopie:
base-headers.inc ueberall unschaedlich | +-- public-headers.inc alles unter vpn21.net, HSTS-Schalter | | | +-- apex-headers.inc vpn21.net / www.vpn21.net | +-- nextcloud-headers.inc | +-- usvn-headers.inc | +-- shop-headers.inc geplant | +-- intern-headers.inc *.p21.net, kein HSTS
Alle Dateien liegen in conf.d/ und folgen dem Namensschema *.inc. Die
Endung ist wichtig: conf.d/*.conf wird von NGINX per Glob automatisch
eingebunden, *.inc nicht.
Einbindung
In den server-Block und in jeden location-Block, der eigene
add_header-Direktiven hat -- am besten als erste Zeile:
server { include conf.d/nextcloud-headers.inc; location ~ \.php$ { include conf.d/nextcloud-headers.inc; add_header Cache-Control "no-store"; ... } }
Prüfung
Zur Prüfung der Konfigurationsdateien existiert ein Script, welches die Migration von Konfigurationen und die Verarbeitung von Vorlagen (z.B. für die Nextcloud-Konfiguration in Nginx) unterstützt.
Vergessene Bloecke finden:
nginx -T | awk '/^\s*location/{loc=$0; has=0} /add_header/{if(loc && !has){print loc; has=1}}'
Jeden Location-Typ einmal abklopfen -- PHP, CSS/JS, Fonts, statische
Dateien, /.well-known/*:
curl -sI https://cloud.vpn21.net/ curl -sI https://cloud.vpn21.net/nextcloud/index.php curl -sI https://cloud.vpn21.net/nextcloud/core/css/server.css
Beim Pruefen im Browser auf den Cache achten: angezeigt werden die Header der urspruenglichen Antwort. Mit Shift-Reload gegenpruefen.
Die einzelnen Header
| Header | Ebene | Begruendung |
|---|---|---|
X-Content-Type-Options: nosniff | base | verhindert MIME-Sniffing, z.B. hochgeladenes Bild als Skript |
X-Permitted-Cross-Domain-Policies: none | base | Adobe-Clients, weitgehend historisch |
X-Frame-Options: SAMEORIGIN | base | Clickjacking-Schutz, abgeloest durch CSP frame-ancestors |
Referrer-Policy | pro Site | Wert unterschiedlich, siehe unten |
Strict-Transport-Security | public / apex | noch nicht aktiviert |
X-Robots-Tag: noindex, nofollow | Nextcloud, USVN | niemals global |
Content-Security-Policy | pro Site | nie global, siehe unten |
X-XSS-Protection | -- | deprecated, nicht setzen |
X-Frame-Options
"same origin" bedeutet Schema + Host + Port. Daraus folgt:
- dienstuebergreifendes Einbetten ist blockiert, z.B. VIS auf
iobroker.p21.net, dasnodered.p21.neteinbettet - eine HTTP-Seite, die die HTTPS-Seite desselben Hosts einbettet, ist bereits cross-origin -- relevant bei einer schrittweisen TLS-Umstellung der internen Dienste
Derzeit betten die internen Dashboards nur vom gleichen Host ein, deshalb
steht SAMEORIGIN in base-headers.inc. Sollte sich das aendern, wandert
der Header in die oeffentlichen Dateien und wird intern durch eine CSP mit
frame-ancestors ersetzt.
Referrer-Policy
Bewusst nicht in base-headers.inc, weil der sinnvolle Wert je Site
abweicht:
no-referrerfuer Nextcloud (setzt es selbst), USVN und den Apexstrict-origin-when-cross-originfuer den Shop --no-referrerbricht unter Umstaenden Zahlungsdienstleister, Affiliate-Rueckverfolgung und Analytics
X-Robots-Tag
Der Hauptgrund gegen eine einzige globale Header-Datei. none ist
aequivalent zu noindex, nofollow; auf dem Shop waere das ein
Totalschaden -- ohne Fehlermeldung, mit Wochen Verzoegerung sichtbar.
Der Selbstcheck von Nextcloud vergleicht ausserdem gegen den Literal-String
noindex, nofollow. Semantisch gleichwertige Schreibweisen wie none
erfuellen ihn nicht.
X-XSS-Protection
Nicht setzen. Der Header steuerte den XSS-Filter von Internet Explorer,
Chrome, Edge und Safari; Firefox hat ihn nie implementiert, Edge hat den
Filter 2018 entfernt, Chrome 2019. Der Filter konnte in eigentlich sicheren
Seiten selbst XSS-Luecken erzeugen. OWASP empfiehlt den Wert 0 oder das
Weglassen. Gegen XSS hilft CSP.
Der Header steht noch in der Nextcloud-Beispielkonfiguration -- Altlast.
Content-Security-Policy
CSP gibt dem Browser eine Positivliste vor, aus welchen Quellen er Skripte, Stylesheets, Bilder, Schriften, Verbindungen und Frames laden darf. Alles nicht Aufgefuehrte wird verweigert. Das ist die eigentliche Verteidigung gegen Cross-Site-Scripting: eingeschleustes Inline-JavaScript? wird nicht ausgefuehrt, weil Inline-Skripte nicht erlaubt sind.
Fuer legitime Inline-Skripte gibt es Nonces: der Server erzeugt pro
Request eine Zufallszahl, schreibt sie in den Header und an jedes eigene
<script>. Eingeschleuste Skripte kennen sie nicht.
CSP gehoert niemals in eine globale Datei. Mehrere CSP-Header werden nicht ueberschrieben, sondern alle durchgesetzt -- der Browser bildet die Schnittmenge. Nextcloud erzeugt seine CSP selbst mit Nonces; eine zweite aus NGINX zerlegt die Oberflaeche.
Fuer den Shop spaeter ueber Content-Security-Policy-Report-Only
einfuehren: derselbe Header, der Browser blockiert nichts und meldet nur,
was er blockiert haette. Einige Wochen auswerten, aufraeumen, dann scharf
schalten.
Doppelte Header aus dem Backend
add_header ergaenzt Header des Backends, es ersetzt sie nicht.
- Nextcloud sendet seine Security-Header selbst aus PHP. In der
fastcgi-Konfiguration
fastcgi_param modHeadersAvailable true;setzen, dann unterlaesst Nextcloud das. - USVN laeuft auf Apache hinter NGINX. Dort
proxy_hide_headerverwenden. Die Direktive folgt derselben Vererbungsregel wieadd_headerund gehoert deshalb in dieselbe Include-Datei. Vorher pruefen, was das Backend tatsaechlich sendet:curl -sI http://127.0.0.1:<backend-port>/
HSTS -- offene Entscheidung
Status: nicht aktiviert. Der Schalter steht auskommentiert in
public-headers.inc und apex-headers.inc.
Wirkung: der Browser merkt sich fuer max-age Sekunden, dass der Host nur
ueber HTTPS angesprochen werden darf. Zertifikatswarnungen sind dann nicht
mehr per Klick uebergehbar.
includeSubDomains erweitert die Policy auf die Subdomains dieses
Hosts, nicht auf Geschwister-Hosts. Ein Header von cloud.vpn21.net gilt
fuer *.cloud.vpn21.net, nicht fuer shop.vpn21.net. Damit alle
*.vpn21.net erfasst werden, muss der Header vom Apex vpn21.net kommen.
Unbedenklich, solange die internen Dienste unter einer getrennten Domain
liegen. Vor einer Migration der internen Dienste unter vpn21.net neu
bewerten -- includeSubDomains wuerde sie dann mit erfassen und HTTP
dort abschalten.
Das woechentliche HTTP-Fenster fuer Let's Encrypt ist nicht betroffen: HSTS ist eine Browser-Policy, der ACME-Validator wertet sie nicht aus, HTTP-01-Challenges funktionieren unveraendert.
preload ist eine separate, nur schwer rueckgaengig zu machende
Entscheidung und bleibt vorerst aussen vor.
Namensraum p21.net -- offener Punkt
Die Domain p21.net ist inzwischen oeffentlich vergeben und nicht unsere;
die interne Nutzung ist knapp 30 Jahre alt und historisch gewachsen.
Bisher keine Konflikte. Die Risiken bestehen unabhaengig vom eigenen Setup:
- DNS-over-HTTPS. Browser loesen zunehmend am lokalen Resolver
vorbei auf. Dann landen Anfragen an
iobroker.p21.netbeim fremden Server -- per Klartext-HTTP, samt Cookies und ggf. Basic-Auth-Credentials. Ohne TLS gibt es auch keine Warnung, die das abfaengt. - HSTS-preload des Eigentuempers mit
includeSubDomainswuerde die HTTP-Verbindung zu den internen Diensten schlagartig und dauerhaft verweigern lassen. - Zertifikate fuer
*.p21.netstellt keine oeffentliche CA aus.
Migrationsoptionen
| Option | Kosten | Bewertung |
|---|---|---|
p21.local | keine | ungeeignet. .local ist per RFC 6762 fuer mDNS reserviert. Avahi, Bonjour und systemd-resolved fangen die Namen ab; auf macOS erreicht eine .local-Anfrage den Unicast-Resolver grundsaetzlich nicht.
|
p21.internal | keine | brauchbar. ICANN hat .internal im Juli 2024 dauerhaft von der Delegation ausgenommen, ausdruecklich fuer private Nutzung. Kann nie von Dritten registriert werden. Zertifikate weiterhin nur ueber eigene CA.
|
p21.vpn21.net | keine | beste Variante bei Bedarf an internem TLS. Split-Horizon-DNS, Wildcard-Zertifikat via DNS-01, keine CA-Verteilung auf den Clients. Wechselwirkung mit HSTS beachten, siehe oben. |
p21.org / p21.site | teuer bzw. vergeben | kein Vorteil gegenueber den kostenlosen Optionen |
Der ICANN-Draft zu .internal empfiehlt selbst die Subdomain einer eigenen
Domain als erste Wahl und die reservierte TLD erst danach.
Content-Security-Policy fuer den Shop
Status: noch nicht aktiv. Die Konfiguration steht auskommentiert in
conf.d/shop-headers.inc.
Wer sammelt die Reports?
Der Browser erzeugt sie, legt sie aber nirgends ab. Ohne Reporting-Direktive erscheinen Verstoesse ausschliesslich in der Entwicklerkonsole des Browsers -- NGINX bekommt davon nichts mit, es gibt kein Log. Mit Reporting-Direktive schickt der Browser einen POST mit JSON-Body an eine URL, die wir angeben. Den Empfaenger muessen wir selbst stellen.
Zwei Mechanismen:
report-uri-- der alte, CSP-eigene Weg, inzwischen deprecatedreport-toplus den HeaderReporting-Endpoints-- der moderne Weg ueber die allgemeine Reporting-API
Browser mit report-to-Unterstuetzung ignorieren report-uri vollstaendig.
Lange war report-uri unverzichtbar, weil Firefox und Safari die
Reporting-API nicht umgesetzt hatten; seit Maerz 2026 unterstuetzen alle
aktuellen Browser report-to (Firefox seit Version 149). Wir verwenden
daher nur noch report-to.
Dreistufiges Vorgehen
- Entwicklerkonsole. Solange der Shop nicht oeffentlich ist, nichts aktivieren. Shop selbst durchklicken -- Katalog, Warenkorb, Checkout, Zahlungsmodule, Admin -- und die Verstoesse in der Konsole abarbeiten. Die zeigt sie sofort und mit Zeilennummer, ein Report-Endpunkt liefert gebuendelt und mit bis zu einer Minute Verzoegerung.
- Report-Only mit Endpunkt. Erst wenn der Shop oeffentlich ist und Felddaten aus echten Browsern gebraucht werden. Blockiert nichts.
- Scharf schalten.
Content-Security-Policy-Report-OnlydurchContent-Security-Policyersetzen, Endpunkt beibehalten.
Endpunkt in NGINX
In nginx.conf, http-Kontext:
log_format csp escape=json '{"time":"$time_iso8601","ua":"$http_user_agent",' '"report":$request_body}'; limit_req_zone $binary_remote_addr zone=csp:1m rate=10r/m; # Dummy-Backend, das den POST quittiert server { listen 127.0.0.1:8099; location / { return 204; } }
Im server-Block des Shops:
location = /_csp-report { access_log /var/log/nginx/csp-report.jsonl csp; client_body_buffer_size 64k; client_max_body_size 64k; limit_req zone=csp burst=5 nodelay; proxy_pass http://127.0.0.1:8099; }
Und im Shop-Header:
add_header Reporting-Endpoints 'csp="https://shop.vpn21.net/_csp-report"' always; add_header Content-Security-Policy-Report-Only "default-src 'self'; report-to csp" always;
Fallstricke:
- Der Umweg ueber
proxy_passist noetig. NGINX liest den Request-Body nur ein, wenn es ihn weiterreicht; bei einem schlichtenreturn 204;bleibt$request_bodyleer. $request_bodyist nur gefuellt, wenn der Body in den Speicher passt. Groesserer Body wandert in eine temporaere Datei, die Variable bleibt leer -- daherclient_body_buffer_sizepassend setzen.- Die Endpunkt-URL muss HTTPS sein. Ueber HTTP verwirft der Browser sie kommentarlos.
escape=jsonimlog_formatgibt es ab NGINX 1.11.8.
Logrotation
NGINX kann nicht selbst rotieren. Es gibt keine Groessen- oder
Zeitbegrenzung in der Konfiguration; NGINX schreibt in ein offenes
File-Handle, bis jemand von aussen eingreift. Der vorgesehene Weg ist:
Datei umbenennen, dann SIGUSR1 an den Master-Prozess, woraufhin NGINX die
Logs neu oeffnet. Genau das macht logrotate.
Begrenzen laesst sich in der NGINX-Konfiguration nur der Zufluss --
client_max_body_size pro Eintrag, limit_req pro Client. Beides ist oben
gesetzt.
Der Dateiname lautet bewusst csp-report.jsonl und nicht .log: die
mitgelieferte logrotate-Konfiguration der Distribution greift ueblicherweise
auf /var/log/nginx/*.log zu. Zwei Stanzas fuer dieselbe Datei quittiert
logrotate mit "duplicate log entry" und bricht ab. Die eigene Endung haelt
die CSP-Logs aus dem Standard-Glob heraus und erlaubt eine eigene Retention.
/etc/logrotate.d/nginx-csp:
/var/log/nginx/csp-report.jsonl {
size 10M
rotate 5
compress
delaycompress
missingok
notifempty
create 0640 www-data adm
sharedscripts
postrotate
[ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid`
endscript
}
Zur Wirkung von size: die Direktive schaltet die zeitbasierte Rotation ab
und rotiert nur noch bei Ueberschreiten der Groesse -- aber geprueft wird
das erst, wenn logrotate laeuft, ueblicherweise einmal taeglich per
systemd-Timer. Eine harte Obergrenze ist das also nicht; bei einem Ausbruch
kann die Datei zwischen zwei Laeufen deutlich groesser werden. Wer das enger
braucht, laesst logrotate oefter laufen (eigener Timer oder Cron-Eintrag mit
logrotate /etc/logrotate.d/nginx-csp).
Alternative maxsize statt size: rotiert nach Zeitplan oder bei
Ueberschreiten der Groesse, je nachdem was zuerst eintritt. Sinnvoll, wenn
die Datei auch bei wenig Verkehr regelmaessig umgebrochen werden soll.
Benutzer und Gruppe in create an die Distribution anpassen -- auf einigen
Systemen nginx:nginx statt www-data:adm.
Rauschfilter -- noch zu entwickeln
Sobald der Endpunkt oeffentlich erreichbar ist, besteht ein erheblicher Teil der Reports aus Muell: Browser-Erweiterungen, die Skripte injizieren, Virenscanner mit TLS-Interception, von Providern eingeschleuste Inhalte. Die Trennung von relevanten und irrelevanten Reports ist eine eigene Aufgabe.
Prompt zum Kopieren, wenn es soweit ist:
Ich betreibe einen modified-eCommerce-Shop unter shop.vpn21.net hinter
NGINX (Version siehe unten). Der Shop laeuft seit einiger Zeit mit
Content-Security-Policy-Report-Only und einem Report-Endpunkt
/_csp-report, der die Reports per report-to einsammelt.
NGINX loggt die Reports mit log_format csp escape=json als eine JSON-Zeile
pro Report nach /var/log/nginx/csp-report.jsonl, Format:
{"time":"...","ua":"...","report":<Reporting-API-Payload>}
Ich haenge einen Auszug der Logdatei an.
Ich moechte mit dir zusammen einen Filter entwickeln, der die relevanten
Reports -- also echte Verstoesse meines eigenen Shop-Codes und seiner
Module -- von Rauschen trennt. Als Rauschen erwarte ich vor allem:
Browser-Erweiterungen, Virenscanner mit TLS-Interception, von Providern
eingeschleuste Inhalte, Bots.
Bitte gehe so vor:
1. Analysiere den Auszug und zeige mir, welche Report-Klassen tatsaechlich
vorkommen und wie haeufig. Rate nicht, zaehle.
2. Schlage Unterscheidungskriterien vor und begruende sie. Nenne mir bei
jedem Kriterium, welche echten Verstoesse es faelschlich ausfiltern
koennte.
3. Erst danach: ein Auswertungsskript. Sprache nach deinem Vorschlag,
begruende die Wahl. Es soll die Logdatei einlesen, Rauschen abtrennen
und die verbleibenden Verstoesse nach Haeufigkeit gruppiert ausgeben.
4. Sag mir, welche CSP-Direktiven ich aus den verbleibenden Verstoessen
ableiten muesste, damit ich von Report-Only auf scharf umstellen kann.
Frag nach, wenn etwas unklar ist, statt auf Annahmen aufzubauen.
Code-Policy fuer C++ gilt hier vermutlich nicht, aber falls doch Code in
C++ entsteht, halte dich daran.
Mitzubringen, wenn wir das angehen: ein Auszug aus csp-report.jsonl von
einigen Tagen bis Wochen, moeglichst ungefiltert, und die dann aktive
Report-Only-Policy.
