wiki:WebDiensteNginxSecurityHeader

Version 3 (modified by Johannes Lode, 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, das nodered.p21.net einbettet
  • 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-referrer fuer Nextcloud (setzt es selbst), USVN und den Apex
  • strict-origin-when-cross-origin fuer den Shop -- no-referrer bricht 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_header verwenden. Die Direktive folgt derselben Vererbungsregel wie add_header und 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.net beim 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 includeSubDomains wuerde die HTTP-Verbindung zu den internen Diensten schlagartig und dauerhaft verweigern lassen.
  • Zertifikate fuer *.p21.net stellt 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, ausdrueck­lich 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 deprecated
  • report-to plus den Header Reporting-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

  1. 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.
  2. Report-Only mit Endpunkt. Erst wenn der Shop oeffentlich ist und Felddaten aus echten Browsern gebraucht werden. Blockiert nichts.
  3. Scharf schalten. Content-Security-Policy-Report-Only durch Content-Security-Policy ersetzen, 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_pass ist noetig. NGINX liest den Request-Body nur ein, wenn es ihn weiterreicht; bei einem schlichten return 204; bleibt $request_body leer.
  • $request_body ist nur gefuellt, wenn der Body in den Speicher passt. Groesserer Body wandert in eine temporaere Datei, die Variable bleibt leer -- daher client_body_buffer_size passend setzen.
  • Die Endpunkt-URL muss HTTPS sein. Ueber HTTP verwirft der Browser sie kommentarlos.
  • escape=json im log_format gibt 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.