Skip to content

IT Reverse Proxy und HTTPS

IT-Master Heizmann edited this page Sep 18, 2026 · 1 revision

Reverse-Proxy und HTTPS

Caution

Diese Seite richtet sich ausschliesslich an Informatikerinnen und Informatiker. Nicht für Laien geeignet: Eine falsche Proxy-Konfiguration kann Anmeldungen verhindern, Rate-Limits und IP-Filter aushebeln oder Konfigurationsdateien öffentlich machen. Ohne Fachkenntnisse bitte den Bereich Installation Schritt für Schritt verwenden.

Für wen ist diese Seite? Für Informatikerinnen und Informatiker, die SmallTime 2027 per HTTPS erreichbar machen. Node.js terminiert kein TLS; ein Reverse-Proxy (nginx, Caddy, Plesk, IIS …) nimmt HTTPS an und leitet an http://127.0.0.1:55000 weiter.

Inhalt

  1. Anforderungen an den Proxy
  2. Einstellungen in SmallTime
  3. nginx
  4. Caddy
  5. Plesk
  6. Cloudflare und Länder-Header
  7. HSTS
  8. Content-Security-Policy
  9. Prüfen

Anforderungen an den Proxy

  • TLS-Terminierung, HTTP → HTTPS-Weiterleitung.
  • Ein Proxy-Hop vor Node.js (trust proxy = 1). Bei mehreren Hops (CDN → nginx → Node) muss der letzte Proxy die echte Client-IP in X-Forwarded-For einsetzen, siehe Cloudflare.
  • X-Forwarded-For und X-Forwarded-Proto setzen; Host durchreichen (Lizenzprüfung nutzt den Hostnamen der Anfrage).
  • WebSocket-Upgrade für /api/live (lange Leerlaufzeiten, Heartbeat alle 30 s).
  • Request-Body bis ca. 50 MB (10 Dateien à 5 MB pro Upload).
  • Keine eigene Content-Security-Policy setzen oder überschreiben; die App setzt sie mit Nonce pro Anfrage.
  • Port 55000 nicht öffentlich erreichbar machen (Bind auf localhost bzw. Firewall).

Einstellungen in SmallTime

.env:

NODE_ENV=production
FRONTEND_ORIGIN=https://zeit.example.ch
TRUST_PROXY=true
SECURE_COOKIE=true

config/security.json: "allowLocalhost": false. Länderfilter (allowedCountries) nur aktivieren, wenn der Proxy/CDN zuverlässig einen Länder-Header setzt.

Einstellung Warum
FRONTEND_ORIGIN exakt die öffentliche Origin. Andere Origins → 403 E1006. Bestimmt die Lizenz-Domain.
TRUST_PROXY=true request.ip = Client-IP aus X-Forwarded-For (für Rate-Limits, Kontosperre-Protokoll, IP-Filter), request.secure aus X-Forwarded-Proto. Ohne diese Einstellung sehen alle Anfragen wie 127.0.0.1 aus.
SECURE_COOKIE=true Cookies immer Secure, HSTS immer – unabhängig davon, ob der Proxy X-Forwarded-Proto korrekt setzt.

Warning

TRUST_PROXY=true ohne vorgeschalteten Proxy erlaubt Clients, ihre IP per X-Forwarded-For zu fälschen. Nur setzen, wenn Node.js ausschliesslich über den Proxy erreichbar ist.


nginx

# /etc/nginx/sites-available/smalltime.conf  (Beispiel)
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

upstream smalltime {
    server 127.0.0.1:55000;
    keepalive 16;
}

server {
    listen 80;
    listen [::]:80;
    server_name zeit.example.ch;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name zeit.example.ch;

    ssl_certificate     /etc/letsencrypt/live/zeit.example.ch/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/zeit.example.ch/privkey.pem;

    client_max_body_size 55m;

    # WebSocket für Live-Updates
    location = /api/live {
        proxy_pass http://smalltime;
        proxy_http_version 1.1;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        $connection_upgrade;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 1h;
        proxy_send_timeout 1h;
    }

    location / {
        proxy_pass http://smalltime;
        proxy_http_version 1.1;
        proxy_set_header Connection        "";
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 120s;
    }
}

Hinweise:

  • X-Forwarded-For $remote_addr ersetzt einen vom Client mitgeschickten Header; mit $proxy_add_x_forwarded_for funktioniert es ebenfalls (Express nimmt bei trust proxy = 1 den letzten Eintrag), der ersetzende Wert ist aber eindeutiger.
  • Keine add_header Content-Security-Policy und kein zusätzliches Strict-Transport-Security im Proxy; beides setzt die App.
  • gzip im Proxy ist nicht nötig, wenn ENABLE_COMPRESSION=true. Wird im Proxy komprimiert, ENABLE_COMPRESSION=false setzen und /api/auth sowie /api/csrf-token von der Kompression ausnehmen (BREACH).

Caddy

Caddy holt Zertifikate automatisch, setzt X-Forwarded-For/X-Forwarded-Proto und reicht WebSocket-Upgrades ohne Zusatzkonfiguration durch.

# /etc/caddy/Caddyfile  (Beispiel)
zeit.example.ch {
    request_body {
        max_size 55MB
    }
    reverse_proxy 127.0.0.1:55000
}

Caddy ersetzt X-Forwarded-For standardmässig durch die Client-IP, solange keine trusted_proxies konfiguriert sind. Das passt zu trust proxy = 1.


Plesk

Plesk startet die App über Phusion Passenger (app.cjs), nginx terminiert TLS. Einrichtung gemäss README.md im Repository:

  • Anwendungsstamm /smalltime, Dokumentstamm /smalltime/public, Startdatei app.cjs, Modus production, Node.js 26.9.0.
  • .env oder Plesk-Umgebungsvariablen: FRONTEND_ORIGIN, SESSION_SECRET, SECURE_COOKIE=true, TRUST_PROXY=true. PORT setzt Passenger.
  • «NPM install», danach «App neu starten».

Caution

Der Dokumentstamm muss auf public zeigen. Zeigt er auf den Anwendungsstamm, liefert nginx .env, config/ und data/ direkt aus.

Note

Die .htaccess-Sperre für .js/.css aus allgemeinen Plesk-Anleitungen nicht verwenden, sonst lädt das Frontend nicht. Ob WebSockets (/api/live) auf dem jeweiligen Plesk/Passenger durchgereicht werden, hängt von der Hosting-Konfiguration ab. Ohne WebSocket funktioniert die App, Änderungen anderer Personen erscheinen dann erst nach dem Neuladen.


Cloudflare und Länder-Header

Der Länderfilter (allowedCountries in security.json) liest den Ländercode aus einem der countryHeaders, z. B. CF-IPCountry von Cloudflare oder CloudFront-Viewer-Country von AWS CloudFront. Die Header werden nur mit TRUST_PROXY=true ausgewertet.

Bei Cloudflare → nginx → Node.js sind es zwei Hops. Damit request.ip die echte Client-IP ist, muss nginx sie aus Cloudflare übernehmen:

# in http{} oder server{}: offizielle Cloudflare-Bereiche (https://www.cloudflare.com/ips/)
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# … alle weiteren IPv4-/IPv6-Bereiche von Cloudflare …
real_ip_header CF-Connecting-IP;

# in den location-Blöcken wie oben:
proxy_set_header X-Forwarded-For $remote_addr;   # jetzt = echte Client-IP

Warning

  • Ist der Ursprungsserver auch direkt (an Cloudflare vorbei) erreichbar, kann ein Client CF-IPCountry selbst senden. Eingehenden Verkehr auf Port 443 auf die Cloudflare-Bereiche beschränken (Firewall oder Authenticated Origin Pulls), oder den Header im nginx nur für Cloudflare-Quellen durchreichen.
  • Ohne Länder-Header und mit nicht leerer allowedCountries wird jede externe Anfrage mit 403 abgelehnt, auch /api/health.
  • allowedIpRanges/blockedIpRanges wirken unabhängig vom Länderfilter, siehe Konfiguration.

HSTS

Die App setzt Strict-Transport-Security: max-age=31536000; includeSubDomains, wenn die Anfrage als sicher gilt: request.secure (mit TRUST_PROXY aus X-Forwarded-Proto: https) oder SECURE_COOKIE=true.

  • includeSubDomains gilt für alle Unterdomains des aufgerufenen Hosts (bei zeit.example.ch also *.zeit.example.ch, nicht example.ch).
  • Kein preload. Wer HSTS-Preload will, setzt den Header im Proxy und muss dann die App-Variante akzeptieren (zwei Header vermeiden: im Proxy proxy_hide_header Strict-Transport-Security;).
  • Über reines HTTP ignorieren Browser den Header.

Content-Security-Policy

Die App setzt die CSP per Helmet, mit einer Nonce pro Anfrage für Styles (Emotion/MUI). Der Platzhalter __CSP_NONCE__ in index.html wird beim Ausliefern ersetzt.

default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';
frame-src 'self' blob:; object-src 'none'; script-src 'self';
style-src 'self' https://fonts.googleapis.com 'nonce-…';
font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:;
connect-src 'self'; media-src 'self'; manifest-src 'self';
script-src-attr 'none'

(script-src-attr stammt aus den Helmet-Standards. upgrade-insecure-requests ist bewusst abgeschaltet, damit der Betrieb über http:// im internen Netz funktioniert.)

  • frame-src blob: ist für die PDF-Vorschau nötig (iframe mit blob:-URL). Eine Proxy-CSP ohne blob: bricht die Vorschau.
  • Betrieb über reines http:// im internen Netz ist möglich (FRONTEND_ORIGIN auf die interne Adresse setzen). Für den Zugriff aus dem Internet immer HTTPS über einen Reverse-Proxy verwenden.
  • Weitere Header: X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin, Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin, Permissions-Policy: camera=(), microphone=(), geolocation=(), kein X-Powered-By.

Prüfen

# Gesundheit (öffentlich, unterliegt Länder-/IP-Filter)
curl -s https://zeit.example.ch/api/health

# Header prüfen: CSP, HSTS, Cookies
curl -sI https://zeit.example.ch/ | grep -iE 'content-security|strict-transport'

# WebSocket-Upgrade (erwartet 101 mit gültiger Session; ohne Session wird die Verbindung abgewiesen,
# aber nicht mit 404 – 404 heisst: Proxy leitet /api/live nicht weiter)
curl -si -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
     -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' https://zeit.example.ch/api/live | head -1

Im Browser: Anmelden, Entwicklertools → Netzwerk → live muss den Status 101 haben. Unter Administration → Protokoll → Anmeldungen muss die echte Client-IP erscheinen, nicht 127.0.0.1 oder die Proxy-IP.


Weiter mit: Sicherheit und Betrieb · Konfiguration · Docker

Clone this wiki locally