Changes between Initial Version and Version 1 of WebDiensteNginxSecurityHeaderScript


Ignore:
Timestamp:
Oct 24, 2026, 1:23:26 PM (2 weeks ago)
Author:
Johannes Lode
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • WebDiensteNginxSecurityHeaderScript

    v1 v1  
     1= nginx-header-diff.sh =
     2
     3[[PageOutline]]
     4
     5Hilfsskript fuer die Migration und Pflege der Security-Header in der
     6NGINX-Konfiguration. Vergleicht die `add_header`-Direktiven einer
     7`.conf`-Datei mit dem effektiven Header-Satz einer Include-Datei und
     8meldet je Block, was doppelt, was abweichend und was nur in der `.conf`
     9steht.
     10
     11Siehe auch: SecurityHeader (Begruendung der einzelnen Header und die
     12Vererbungsregel von `add_header`).
     13
     14== Wozu ==
     15
     16`add_header` wird von der uebergeordneten Ebene nur dann geerbt, wenn auf
     17der aktuellen Ebene '''kein einziges''' `add_header` steht. Jeder
     18`location`-Block mit eigenem `add_header` braucht deshalb ein eigenes
     19`include` der passenden `<site>-headers.inc`. Das Skript findet die
     20Bloecke, in denen das fehlt, und zeigt, welche Zeilen danach ueberfluessig
     21sind.
     22
     23Zwei Einsatzfaelle:
     24
     25 * '''Migration.''' Eine bestehende `.conf` auf die Include-Struktur
     26   umstellen.
     27 * '''Vorlagen.''' Eine fremde Beispielkonfiguration (etwa die offizielle
     28   von Nextcloud) uebernehmen und die mitgelieferten Header gegen die
     29   eigenen abgleichen.
     30
     31Das Skript aendert '''nichts'''. Es zeigt nur an.
     32
     33== Aufruf ==
     34
     35{{{#!sh
     36nginx-header-diff.sh [-p PREFIX] [-d DIREKTIVE] <datei.conf> <datei.inc>
     37}}}
     38
     39||= Option =||= Bedeutung =||
     40|| `-p PREFIX` || Basisverzeichnis fuer relative `include`-Pfade. Vorgabe `/etc/nginx`. ||
     41|| `-d DIREKTIVE` || Zusaetzliche Direktive beruecksichtigen, mehrfach angebbar. Vorgabe nur `add_header`. Sinnvoll: `-d proxy_hide_header`. ||
     42|| `-h` || Kurzhilfe. ||
     43
     44Beispiele:
     45
     46{{{#!sh
     47cd /etc/nginx
     48./nginx-header-diff.sh cloudservice.conf conf.d/nextcloud-headers.inc
     49./nginx-header-diff.sh -d proxy_hide_header usvn.conf conf.d/usvn-headers.inc
     50./nginx-header-diff.sh -p /etc/nginx elager.p21.net.conf conf.d/intern-headers.inc
     51}}}
     52
     53Rueckgabewert 2 bei Aufruffehlern oder nicht lesbaren Dateien, sonst 0.
     54Der Rueckgabewert sagt '''nichts''' darueber aus, ob Abweichungen gefunden
     55wurden.
     56
     57== Ausgabe ==
     58
     59Zwei Abschnitte. Zuerst der effektive Header-Satz der Include-Datei,
     60nachdem alle `include`-Ketten rekursiv aufgeloest wurden -- in eckigen
     61Klammern die Datei und Zeile, aus der der Header tatsaechlich stammt:
     62
     63{{{
     64=== Effektiver Header-Satz aus conf.d/nextcloud-headers.inc ===
     65  add_header X-Content-Type-Options "nosniff" always;     [/etc/nginx/conf.d/base-headers.inc:54]
     66  add_header X-Frame-Options "SAMEORIGIN" always;         [/etc/nginx/conf.d/base-headers.inc:56]
     67  add_header X-Robots-Tag "noindex, nofollow" always;     [conf.d/nextcloud-headers.inc:35]
     68}}}
     69
     70Danach die `.conf`, nach Bloecken gegliedert. Bloecke ohne `add_header`
     71tauchen nicht auf -- die brauchen auch kein `include`:
     72
     73{{{
     74=== cloudservice.conf ===
     75
     76server   (Zeile 2)
     77  include nextcloud-headers.inc: FEHLT -> einfuegen
     78  cloudservice.conf:7: add_header X-Content-Type-Options "nosniff" always;  [DUPLIKAT]
     79  cloudservice.conf:8: add_header Referrer-Policy "no-referrer" always;  [NUR-CONF]
     80
     81  location ~ \.(?:css|js|svg|gif)$   (Zeile 24)
     82    include nextcloud-headers.inc: FEHLT -> einfuegen
     83    cloudservice.conf:27: add_header X-Robots-Tag "none" always;  [KONFLIKT]
     84        Include hat: add_header X-Robots-Tag "noindex, nofollow" always;
     85
     86---
     87DUPLIKAT 5   KONFLIKT 1   NUR-CONF 5
     88}}}
     89
     90Die Fundstellen folgen dem Format von `grep -n`: `datei:zeile: text`.
     91Zitiert wird in der Originalschreibweise der jeweiligen Datei,
     92einschliesslich Semikolon -- die Zeilen lassen sich also direkt kopieren.
     93
     94=== Markierungen ===
     95
     96||= Marke =||= Bedeutung =||= Massnahme =||
     97|| `[DUPLIKAT]` || Name und Wert identisch mit dem Include || Zeile loeschen, sobald das `include` steht ||
     98|| `[KONFLIKT]` || Name gleich, Wert abweichend || von Hand entscheiden ||
     99|| `[NUR-CONF]` || im Include nicht enthalten || Zeile behalten ||
     100
     101=== Vergleichsregeln ===
     102
     103 * Header-'''Namen''' werden case-insensitiv verglichen, wie es HTTP
     104   vorsieht. Angezeigt wird die Schreibweise aus der Datei.
     105 * '''Werte''' werden vor dem Vergleich normiert: Anfuehrungszeichen
     106   entfernt, Whitespace zusammengefasst. `nosniff always` und
     107   `"nosniff" always` gelten daher als identisch.
     108 * `always` zaehlt zum Wert. `add_header X-Content-Type-Options "nosniff";`
     109   gegen `"nosniff" always` im Include ergibt `[KONFLIKT]`, nicht
     110   `[DUPLIKAT]`. Das ist Absicht: ohne `always` wird der Header bei
     111   Fehlerseiten und Redirects nicht gesendet, der Unterschied ist real.
     112   Beim Aufraeumen ist es trotzdem fast immer ein Loeschkandidat -- aber
     113   einer, der gesehen werden soll.
     114
     115== Arbeitsablauf bei der Migration ==
     116
     117 1. Skript laufen lassen, Ausgabe aufheben.
     118 2. `include conf.d/<site>-headers.inc;` als '''erste''' Zeile in jeden
     119    gemeldeten Block eintragen, in dem es fehlt.
     120 3. `nginx -t`
     121 4. Erst jetzt die `[DUPLIKAT]`-Zeilen loeschen, `[KONFLIKT]`-Zeilen
     122    einzeln entscheiden.
     123 5. `nginx -t`, dann `nginx -s reload`
     124 6. Skript erneut laufen lassen -- es sollten nur noch `[NUR-CONF]` und
     125    bewusst behaltene `[KONFLIKT]` uebrig sein.
     126 7. Pro Block-Typ mit `curl -sI` gegenpruefen. `nginx -t` prueft Syntax,
     127    nicht Wirkung.
     128
     129'''Reihenfolge beachten:''' erst das `include` einfuegen, dann die
     130Duplikate loeschen. Umgekehrt steht die Konfiguration zwischenzeitlich
     131ohne Security-Header da.
     132
     133Pruefung mit curl, jeweils gegen genau den Pfad, den der Block bedient:
     134
     135{{{#!sh
     136curl -sI https://cloud.vpn21.net/
     137curl -sI https://cloud.vpn21.net/nextcloud/index.php
     138curl -sI https://cloud.vpn21.net/nextcloud/core/css/server.css
     139}}}
     140
     141Im Browser auf den Cache achten -- angezeigt werden die Header der
     142urspruenglichen Antwort. Mit Shift-Reload gegenpruefen.
     143
     144== Grenzen ==
     145
     146 * `include`-Pfade mit Platzhaltern (`conf.d/*.inc`) werden nicht
     147   aufgeloest.
     148 * Geschweifte Klammern innerhalb von Direktivwerten -- etwa in einem
     149   `log_format` -- koennen die Blockerkennung stoeren.
     150 * Include-Zyklen werden erkannt und mit einer Warnung auf stderr
     151   uebersprungen; Verschachtelungstiefe maximal 16.
     152 * Nicht lesbare Include-Dateien erzeugen eine Warnung auf stderr, das
     153   Skript laeuft weiter -- der effektive Header-Satz ist dann
     154   unvollstaendig. Warnungen also nicht uebersehen.
     155 * Das Skript kennt nur die Datei, die ihm uebergeben wird. Header aus
     156   `nginx.conf` oder aus anderen per Glob eingebundenen Dateien sieht es
     157   nicht.
     158 * Der Abgleich der `include`-Zeilen erfolgt ueber den '''Basisnamen''' der
     159   Include-Datei. Zwei gleichnamige Includes in verschiedenen
     160   Verzeichnissen werden nicht unterschieden.
     161
     162== Gegenprobe ueber die Gesamtkonfiguration ==
     163
     164Das Skript kennt nur die Datei, die ihm uebergeben wird. Header aus
     165`nginx.conf` selbst oder aus anderen per Glob eingebundenen Dateien sieht
     166es nicht -- und genau solche erben in den `server`-Block hinein. Wenn nach
     167der Migration ein Header fehlt oder doppelt auftaucht, den das Skript nicht
     168gemeldet hat, liegt die Ursache ausserhalb seines Blickfelds. Die folgenden
     169Pruefungen schliessen diese Luecke.
     170
     171=== Alle add_header in der entfalteten Konfiguration ===
     172
     173`nginx -T` gibt die Konfiguration inklusive aller aufgeloesten `include`
     174aus:
     175
     176{{{#!sh
     177nginx -T | grep -n 'add_header\|proxy_hide_header'
     178}}}
     179
     180'''Vorsicht:''' die Ausgabe enthaelt die kompletten Konfigurationsdateien,
     181unter Umstaenden also auch Zugangsdaten aus `proxy_pass`-Zielen,
     182`auth_basic_user_file`-Pfaden oder aehnlichem. Nicht unbesehen in Tickets
     183oder Foren kopieren.
     184
     185=== Bloecke mit add_header finden ===
     186
     187Listet jeden `location`- oder `server`-Block, in dem mindestens ein
     188`add_header` steht -- also jeden Block, der ein `include` braucht:
     189
     190{{{#!sh
     191nginx -T | awk '/^[[:space:]]*(location|server)/ {loc=$0; has=0}
     192                /add_header/ {if (loc && !has) {print loc; has=1}}'
     193}}}
     194
     195Bei einer frisch migrierten Konfiguration sollte zu jedem hier gelisteten
     196Block auch ein `include` gehoeren. Das Skript prueft das pro Datei, dieser
     197Aufruf prueft es ueber alles.
     198
     199=== Doppelte Header in der Antwort ===
     200
     201Faengt die Faelle, in denen die Anwendung ihre Header selbst setzt und
     202NGINX sie zusaetzlich anhaengt -- Nextcloud aus PHP, USVN aus dem Apache.
     203Gibt die Namen aus, die mehr als einmal vorkommen:
     204
     205{{{#!sh
     206curl -sI https://cloud.vpn21.net/nextcloud/index.php \
     207  | awk -F: '/^[A-Za-z-]+:/ {print tolower($1)}' | sort | uniq -d
     208}}}
     209
     210Leere Ausgabe ist das Ziel. `set-cookie` darf legitim mehrfach auftreten
     211und ist kein Befund.
     212
     213Gegenmittel: `fastcgi_param modHeadersAvailable true;` bei Nextcloud,
     214`proxy_hide_header` bei Apache-Backends -- siehe SecurityHeader.
     215
     216=== Header-Satz pro URL pruefen ===
     217
     218Prueft mehrere Endpunkte gegen die erwartete Liste. Jeder Block-Typ einer
     219Site braucht einen eigenen Eintrag: PHP, Assets, Fonts, statische Dateien,
     220`/.well-known/*`:
     221
     222{{{#!sh
     223#!/bin/sh
     224erwartet='x-content-type-options x-frame-options
     225          x-permitted-cross-domain-policies referrer-policy'
     226
     227for u in \
     228    https://www.vpn21.net/ \
     229    https://cloud.vpn21.net/ \
     230    https://cloud.vpn21.net/nextcloud/index.php \
     231    https://cloud.vpn21.net/nextcloud/core/css/server.css
     232do
     233    echo "== $u"
     234    h=$(curl -sI "$u" | tr 'A-Z' 'a-z')
     235    echo "$h" | head -1 | sed 's/^/   /'
     236    for k in $erwartet; do
     237        echo "$h" | grep -q "^$k:" || echo "   FEHLT: $k"
     238    done
     239done
     240}}}
     241
     242Bewusst ohne `curl -L`: ein Redirect soll '''nicht''' verfolgt werden,
     243sondern selbst geprueft werden.
     244
     245=== Redirects und Fehlerseiten ===
     246
     247Der haeufigste blinde Fleck. Ohne `always` sendet NGINX einen Header nur
     248bei den Statuscodes 200, 201, 204, 206, 301, 302, 303, 304, 307 und 308 --
     249bei 403, 404 oder 500 also nicht. Deshalb steht in den Include-Dateien
     250ueberall `always`.
     251
     252{{{#!sh
     253curl -sI https://cloud.vpn21.net/gibtsnicht          # erwartet 404
     254curl -sI https://irgendwas.vpn21.net/                # Catch-all-Redirect
     255}}}
     256
     257In beiden Antworten muss der vollstaendige Header-Satz stehen. Fehlt er
     258beim 404, aber nicht beim 200, fehlt irgendwo ein `always`.
     259
     260=== Nach jeder Aenderung ===
     261
     262{{{#!sh
     263nginx -t && nginx -s reload
     264}}}
     265
     266`nginx -t` prueft die Syntax, nicht die Wirkung. Die Wirkung zeigt nur
     267`curl`.
     268
     269== Technisches ==
     270
     271Bash-Wrapper um mehrere AWK-Programme. Keine Abhaengigkeiten ausser einer
     272POSIX-Shell und `awk`; getestet mit `mawk` 1.3.4.
     273
     274Der Scanner arbeitet zeichenweise mit Quote-Status. Das ist noetig, weil
     275`;` `{` `}` innerhalb von Anfuehrungszeichen vorkommen und eine naive
     276Trennung die Direktive zerreisst -- etwa bei
     277`"max-age=63072000; includeSubDomains"` oder `"1; mode=block"`. Direktiven
     278ueber mehrere Zeilen werden zusammengefasst und mit der Zeilennummer ihres
     279Anfangs gemeldet. Kommentare werden ignoriert, auch am Zeilenende, aber
     280nicht innerhalb von Anfuehrungszeichen.
     281
     282== Offen ==
     283
     284 * '''Patch-Modus.''' Technisch machbar: `[DUPLIKAT]`-Zeilen entfernen und
     285   fehlende `include` einsetzen, mit `-n` fuer einen Trockenlauf und einer
     286   `.bak`-Kopie. Bei den derzeit drei betroffenen `.conf`-Dateien ist
     287   Handarbeit sicherer. Fuer die Vorlagen-Nutzung spaeter neu bewerten.
     288 * '''Mehrere `.inc` gegen eine `.conf`.''' Derzeit genau eine
     289   Include-Datei pro Aufruf.
     290}}}