NGINX Proxy Manager härten - SNI und Hostnamen aneinander binden
Einleitung
Bei einem ersten eigenen PenTest meines Reverse Proxys ist eine kleine, aber
interessante Eigenheit aufgefallen: Im TLS-Handshake konnte ich einen Hostnamen
angeben und im anschließenden HTTP-Request einen anderen. Nginx Proxy Manager
hat die Anfrage trotzdem angenommen und anhand des HTTP-Host-Headers an den
zweiten Proxy Host weitergeleitet.
Das ist erst einmal kein Fehler in Nginx. Der SNI-Name wird schon beim Aufbau
der verschlüsselten Verbindung benötigt, damit Nginx das passende Zertifikat
auswählen kann. Den HTTP-Host sieht Nginx dagegen erst innerhalb der danach
entschlüsselten Anfrage. Von Haus aus verlangt Nginx nicht, dass beide Angaben
identisch sind.
Solange alle Proxy Hosts exakt dieselben Zugriffsregeln haben, ist das häufig
nur unschön. Spannend wird es aber, sobald ein Host später eine eigene
IP-Freigabe, mTLS oder eine andere Sonderbehandlung bekommt. Dann könnte eine
Anfrage über den Namen eines erlaubten Hosts aufgebaut und anschließend zu
einem anderen Host gelenkt werden. Deshalb habe ich SNI und HTTP-Host
vorsorglich fest aneinander gebunden.
Was nach der Änderung passieren soll
Bei einer normalen HTTPS-Anfrage sind SNI und Host identisch und alles läuft
weiter wie bisher. Passen die Namen nicht zusammen, beendet Nginx ab sofort
die Anfrage mit:
421 Misdirected Request
Der Statuscode passt hier ziemlich gut: Die Verbindung an sich funktioniert, aber sie wurde nicht für den im HTTP-Request verlangten Host aufgebaut.
Reine HTTP-Aufrufe dürfen von der Prüfung nicht betroffen sein. Das ist unter anderem für Weiterleitungen auf HTTPS und gegebenenfalls für ACME-Challenges wichtig (sollte man noch HTTP-01 verwenden).
Das verwendete Nginx-Snippet
Die naheliegende Ein-Zeilen-Prüfung ist für einen ausschließlich mit TLS betriebenen Serverblock ausreichend:
if ($ssl_server_name != $host) {
return 421;
}
Nginx Proxy Manager kann eigene Einstellungen jedoch auch in Serverblöcke
einbauen, die HTTP verarbeiten. Dort ist $ssl_server_name leer. Die kurze
Variante würde dann selbst einen normalen HTTP-Aufruf mit 421 ablehnen.
Deshalb alles etwas komplizierter und eine Aktivierung ausschließlich bei HTTPS:
set $sni_host_mismatch 0;
if ($https = on) {
set $sni_host_mismatch 1;
}
if ($ssl_server_name = $host) {
set $sni_host_mismatch 0;
}
if ($sni_host_mismatch = 1) {
return 421;
}
Die drei if-Blöcke schreiben keine URL um und enthalten keine
Proxy-Anweisungen. Sie setzen nur eine Variable oder beenden die Anfrage mit
return. Das bleibt überschaubar und vermeidet die üblichen Probleme, die
komplexe Nginx-Konfigurationen innerhalb von if verursachen können.
Einbau in den NPM
Möglichkeit 1: direkt am Proxy Host in der Oberfläche
Bei wenigen Hosts lässt sich das Snippet direkt in Nginx Proxy Manager eintragen:
- den gewünschten Proxy Host öffnen;
- das kleine Rad Settings anklicken;
- das vollständige Snippet in Custom Nginx Configuration einfügen;
- speichern und den Host anschließend testen;
- die Änderung bei allen weiteren öffentlichen Proxy Hosts wiederholen.
Das ist die angenehmste Variante zum Ausprobieren. Ich sehe sofort, welcher Host die zusätzliche Prüfung besitzt, und kann zunächst nur einen einzelnen Host umstellen.
Der Nachteil zeigt sich später: Das Snippet wird mehrfach gepflegt und bei einem neu angelegten Proxy Host leicht vergessen. Für eine kleine, stabile Umgebung ist die Lösung trotzdem völlig in Ordnung.
Möglichkeit 2: zentral über die Custom-Konfiguration
Soll die Prüfung automatisch für alle vorhandenen und zukünftigen Proxy Hosts
gelten, kann sie zentral in das persistente NPM-Datenverzeichnis gelegt werden.
Die von NPM generierten Dateien unter /data/nginx/proxy_host/ fasse ich dabei
nicht an – sie werden bei Änderungen über die Oberfläche neu erzeugt.
Das Snippet kommt stattdessen zum Beispiel in:
/data/nginx/custom/sni-host-validation.conf
In /data/nginx/custom/server_proxy.conf wird es anschließend eingebunden:
include /data/nginx/custom/sni-host-validation.conf;
server_proxy.conf wird von Nginx Proxy Manager in die erzeugten
Proxy-Serverblöcke aufgenommen. Existiert dort bereits eine eigene
Konfiguration, sichere und prüfe ich sie vor der Änderung, statt die Datei
einfach zu überschreiben.
Vor dem Neuladen teste ich immer erst die komplette Nginx-Konfiguration:
docker exec <NPM-CONTAINER> nginx -t
Nur wenn der Test erfolgreich ist, wird sie neu geladen:
docker exec <NPM-CONTAINER> nginx -s reload
Die zentrale Variante ist weniger fehleranfällig, weil auch ein später angelegter Proxy Host automatisch geschützt ist. Dafür wirkt eine fehlerhafte Änderung sofort auf alle Hosts. Ein vorheriger Syntaxcheck und ein schneller Funktionstest der wichtigsten Dienste gehören deshalb unbedingt dazu.
Beide Varianten gleichzeitig zu verwenden bringt keinen Vorteil. Ich entscheide mich entweder für die Einträge in der Oberfläche oder für die zentrale Custom-Konfiguration.
Testen, ob die Regel wirklich greift
Zuerst rufe ich alle Dienste ganz normal im Browser auf. Anmeldung, Weiterleitungen und WebSockets sollen unverändert funktionieren. Danach folgt ein absichtlich falsch zusammengesetzter Request:
curl --http1.1 --silent --show-error --output /dev/null \
--write-out '%{http_code}\n' \
--resolve git.example.net:443:203.0.113.10 \
https://git.example.net/ \
-H 'Host: home.example.net'
--resolve verbindet den Test gezielt mit der angegebenen Adresse und sendet
im TLS-Handshake den Namen git.example.net. Der zusätzliche Header verlangt
innerhalb dieser Verbindung dagegen home.example.net. Vor der Härtung kann
dadurch die zweite Anwendung antworten. Danach ist ausschließlich 421 das
gewünschte Ergebnis.
Bei mehreren öffentlichen Hosts teste ich idealerweise alle Kombinationen. Ein einzelnes erfolgreiches Beispiel beweist nur, dass genau dieses Paar abgewiesen wird.
Kleine Stolperfallen
- Gewollte Aliasnamen wie
www.example.netundexample.netsind technisch verschiedene Namen. Entweder bekommen sie eigene passende Proxy Hosts oder werden vor dem globalen Rollout bewusst getestet. - Sehr alte Clients ohne SNI können mit der strikten Zuordnung nicht sinnvoll arbeiten. Für normale aktuelle Browser und Apps spielt das praktisch keine Rolle mehr.
- HTTP/2 darf Verbindungen unter bestimmten Bedingungen wiederverwenden. Ein sauberer Client kann dabei einmal 421 erhalten und baut anschließend eine passende Verbindung auf.
- Nach einem NPM-Update kontrolliere ich, ob die zentrale Custom-Datei weiterhin an derselben Stelle eingebunden wird.
Fazit
Die Änderung ist klein, schließt aber eine Lücke zwischen TLS- und HTTP-Routing. In meiner Umgebung ist sie bei einem aktiven Test aufgefallen, bevor unterschiedliche Schutzregeln der Proxy Hosts daraus ein echtes Problem machen konnten.

No comments to display
No comments to display