| | 1 | = nginx-header-diff.sh = |
| | 2 | |
| | 3 | [[PageOutline]] |
| | 4 | |
| | 5 | Hilfsskript fuer die Migration und Pflege der Security-Header in der |
| | 6 | NGINX-Konfiguration. Vergleicht die `add_header`-Direktiven einer |
| | 7 | `.conf`-Datei mit dem effektiven Header-Satz einer Include-Datei und |
| | 8 | meldet je Block, was doppelt, was abweichend und was nur in der `.conf` |
| | 9 | steht. |
| | 10 | |
| | 11 | Siehe auch: SecurityHeader (Begruendung der einzelnen Header und die |
| | 12 | Vererbungsregel von `add_header`). |
| | 13 | |
| | 14 | == Wozu == |
| | 15 | |
| | 16 | `add_header` wird von der uebergeordneten Ebene nur dann geerbt, wenn auf |
| | 17 | der 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 |
| | 20 | Bloecke, in denen das fehlt, und zeigt, welche Zeilen danach ueberfluessig |
| | 21 | sind. |
| | 22 | |
| | 23 | Zwei 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 | |
| | 31 | Das Skript aendert '''nichts'''. Es zeigt nur an. |
| | 32 | |
| | 33 | == Aufruf == |
| | 34 | |
| | 35 | {{{#!sh |
| | 36 | nginx-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 | |
| | 44 | Beispiele: |
| | 45 | |
| | 46 | {{{#!sh |
| | 47 | cd /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 | |
| | 53 | Rueckgabewert 2 bei Aufruffehlern oder nicht lesbaren Dateien, sonst 0. |
| | 54 | Der Rueckgabewert sagt '''nichts''' darueber aus, ob Abweichungen gefunden |
| | 55 | wurden. |
| | 56 | |
| | 57 | == Ausgabe == |
| | 58 | |
| | 59 | Zwei Abschnitte. Zuerst der effektive Header-Satz der Include-Datei, |
| | 60 | nachdem alle `include`-Ketten rekursiv aufgeloest wurden -- in eckigen |
| | 61 | Klammern 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 | |
| | 70 | Danach die `.conf`, nach Bloecken gegliedert. Bloecke ohne `add_header` |
| | 71 | tauchen nicht auf -- die brauchen auch kein `include`: |
| | 72 | |
| | 73 | {{{ |
| | 74 | === cloudservice.conf === |
| | 75 | |
| | 76 | server (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 | --- |
| | 87 | DUPLIKAT 5 KONFLIKT 1 NUR-CONF 5 |
| | 88 | }}} |
| | 89 | |
| | 90 | Die Fundstellen folgen dem Format von `grep -n`: `datei:zeile: text`. |
| | 91 | Zitiert wird in der Originalschreibweise der jeweiligen Datei, |
| | 92 | einschliesslich 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 |
| | 130 | Duplikate loeschen. Umgekehrt steht die Konfiguration zwischenzeitlich |
| | 131 | ohne Security-Header da. |
| | 132 | |
| | 133 | Pruefung mit curl, jeweils gegen genau den Pfad, den der Block bedient: |
| | 134 | |
| | 135 | {{{#!sh |
| | 136 | curl -sI https://cloud.vpn21.net/ |
| | 137 | curl -sI https://cloud.vpn21.net/nextcloud/index.php |
| | 138 | curl -sI https://cloud.vpn21.net/nextcloud/core/css/server.css |
| | 139 | }}} |
| | 140 | |
| | 141 | Im Browser auf den Cache achten -- angezeigt werden die Header der |
| | 142 | urspruenglichen 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 | |
| | 164 | Das Skript kennt nur die Datei, die ihm uebergeben wird. Header aus |
| | 165 | `nginx.conf` selbst oder aus anderen per Glob eingebundenen Dateien sieht |
| | 166 | es nicht -- und genau solche erben in den `server`-Block hinein. Wenn nach |
| | 167 | der Migration ein Header fehlt oder doppelt auftaucht, den das Skript nicht |
| | 168 | gemeldet hat, liegt die Ursache ausserhalb seines Blickfelds. Die folgenden |
| | 169 | Pruefungen schliessen diese Luecke. |
| | 170 | |
| | 171 | === Alle add_header in der entfalteten Konfiguration === |
| | 172 | |
| | 173 | `nginx -T` gibt die Konfiguration inklusive aller aufgeloesten `include` |
| | 174 | aus: |
| | 175 | |
| | 176 | {{{#!sh |
| | 177 | nginx -T | grep -n 'add_header\|proxy_hide_header' |
| | 178 | }}} |
| | 179 | |
| | 180 | '''Vorsicht:''' die Ausgabe enthaelt die kompletten Konfigurationsdateien, |
| | 181 | unter Umstaenden also auch Zugangsdaten aus `proxy_pass`-Zielen, |
| | 182 | `auth_basic_user_file`-Pfaden oder aehnlichem. Nicht unbesehen in Tickets |
| | 183 | oder Foren kopieren. |
| | 184 | |
| | 185 | === Bloecke mit add_header finden === |
| | 186 | |
| | 187 | Listet jeden `location`- oder `server`-Block, in dem mindestens ein |
| | 188 | `add_header` steht -- also jeden Block, der ein `include` braucht: |
| | 189 | |
| | 190 | {{{#!sh |
| | 191 | nginx -T | awk '/^[[:space:]]*(location|server)/ {loc=$0; has=0} |
| | 192 | /add_header/ {if (loc && !has) {print loc; has=1}}' |
| | 193 | }}} |
| | 194 | |
| | 195 | Bei einer frisch migrierten Konfiguration sollte zu jedem hier gelisteten |
| | 196 | Block auch ein `include` gehoeren. Das Skript prueft das pro Datei, dieser |
| | 197 | Aufruf prueft es ueber alles. |
| | 198 | |
| | 199 | === Doppelte Header in der Antwort === |
| | 200 | |
| | 201 | Faengt die Faelle, in denen die Anwendung ihre Header selbst setzt und |
| | 202 | NGINX sie zusaetzlich anhaengt -- Nextcloud aus PHP, USVN aus dem Apache. |
| | 203 | Gibt die Namen aus, die mehr als einmal vorkommen: |
| | 204 | |
| | 205 | {{{#!sh |
| | 206 | curl -sI https://cloud.vpn21.net/nextcloud/index.php \ |
| | 207 | | awk -F: '/^[A-Za-z-]+:/ {print tolower($1)}' | sort | uniq -d |
| | 208 | }}} |
| | 209 | |
| | 210 | Leere Ausgabe ist das Ziel. `set-cookie` darf legitim mehrfach auftreten |
| | 211 | und ist kein Befund. |
| | 212 | |
| | 213 | Gegenmittel: `fastcgi_param modHeadersAvailable true;` bei Nextcloud, |
| | 214 | `proxy_hide_header` bei Apache-Backends -- siehe SecurityHeader. |
| | 215 | |
| | 216 | === Header-Satz pro URL pruefen === |
| | 217 | |
| | 218 | Prueft mehrere Endpunkte gegen die erwartete Liste. Jeder Block-Typ einer |
| | 219 | Site braucht einen eigenen Eintrag: PHP, Assets, Fonts, statische Dateien, |
| | 220 | `/.well-known/*`: |
| | 221 | |
| | 222 | {{{#!sh |
| | 223 | #!/bin/sh |
| | 224 | erwartet='x-content-type-options x-frame-options |
| | 225 | x-permitted-cross-domain-policies referrer-policy' |
| | 226 | |
| | 227 | for 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 |
| | 232 | do |
| | 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 |
| | 239 | done |
| | 240 | }}} |
| | 241 | |
| | 242 | Bewusst ohne `curl -L`: ein Redirect soll '''nicht''' verfolgt werden, |
| | 243 | sondern selbst geprueft werden. |
| | 244 | |
| | 245 | === Redirects und Fehlerseiten === |
| | 246 | |
| | 247 | Der haeufigste blinde Fleck. Ohne `always` sendet NGINX einen Header nur |
| | 248 | bei den Statuscodes 200, 201, 204, 206, 301, 302, 303, 304, 307 und 308 -- |
| | 249 | bei 403, 404 oder 500 also nicht. Deshalb steht in den Include-Dateien |
| | 250 | ueberall `always`. |
| | 251 | |
| | 252 | {{{#!sh |
| | 253 | curl -sI https://cloud.vpn21.net/gibtsnicht # erwartet 404 |
| | 254 | curl -sI https://irgendwas.vpn21.net/ # Catch-all-Redirect |
| | 255 | }}} |
| | 256 | |
| | 257 | In beiden Antworten muss der vollstaendige Header-Satz stehen. Fehlt er |
| | 258 | beim 404, aber nicht beim 200, fehlt irgendwo ein `always`. |
| | 259 | |
| | 260 | === Nach jeder Aenderung === |
| | 261 | |
| | 262 | {{{#!sh |
| | 263 | nginx -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 | |
| | 271 | Bash-Wrapper um mehrere AWK-Programme. Keine Abhaengigkeiten ausser einer |
| | 272 | POSIX-Shell und `awk`; getestet mit `mawk` 1.3.4. |
| | 273 | |
| | 274 | Der Scanner arbeitet zeichenweise mit Quote-Status. Das ist noetig, weil |
| | 275 | `;` `{` `}` innerhalb von Anfuehrungszeichen vorkommen und eine naive |
| | 276 | Trennung die Direktive zerreisst -- etwa bei |
| | 277 | `"max-age=63072000; includeSubDomains"` oder `"1; mode=block"`. Direktiven |
| | 278 | ueber mehrere Zeilen werden zusammengefasst und mit der Zeilennummer ihres |
| | 279 | Anfangs gemeldet. Kommentare werden ignoriert, auch am Zeilenende, aber |
| | 280 | nicht 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 | }}} |