diff --git a/.env.example b/.env.example index 5cc4282..b9f9d55 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,17 @@ APP_PORT=3000 # Wer den Zugang verliert, setzt den Wert auf false und startet neu. AUTH_ENABLED=true +# Steht ein Reverse Proxy davor, der `X-Forwarded-For` selbst setzt? +# false — Vorgabe. Die Kopfzeile wird nicht gelesen. Ohne Proxy koennte +# sie jeder Aufrufer selbst waehlen; eine Bremse, die sich so +# umgehen laesst, waere eine Behauptung. +# true — Nur setzen, wenn wirklich ein Proxy davorsteht und die Kopfzeile +# ueberschreibt. Sonst schenkst du jedem Aufrufer beliebig viele +# Anmeldeversuche. +# Unabhaengig davon gelten immer: die Wartezeit je Konto und die Obergrenze +# fuer die ganze Installation. +TRUST_PROXY=false + # Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte. # Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr # grosse Werte machen die Sicherung entsprechend schwer. diff --git a/README.md b/README.md index 70032ce..3732610 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ sie eintragen. | --- | --- | --- | | `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit | | `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet | -| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Alle Aufrufe zählen als derselbe Absender | +| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Die Bremse je Absender entfällt; Wartezeit je Konto und Obergrenze der Instanz greifen weiter | Für ein reines nginx (ohne NPM) heißt das: @@ -305,6 +305,14 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen von sich aus. +> **`TRUST_PROXY=true` erst setzen, wenn der Proxy wirklich steht.** Die +> Anwendung liest `X-Forwarded-For` nur mit dieser Angabe. Ohne Proxy könnte +> die Kopfzeile jeder Aufrufer selbst wählen und sich damit beliebig viele +> Anmeldeversuche verschaffen — eine Bremse, die sich so umgehen lässt, wäre +> eine Behauptung. Zwei Bremsen wirken unabhängig davon immer: die Wartezeit +> je Konto und die Obergrenze für die ganze Installation. Näheres in +> [docs/sicherheit.md](docs/sicherheit.md). + ### Größe der Uploads PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von diff --git a/docker-compose.yml b/docker-compose.yml index 62584e5..7237a44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,9 @@ services: # Ohne Angabe: an. Abgeschaltet wird nur, wer ausdruecklich # AUTH_ENABLED=false setzt — siehe .env.example. AUTH_ENABLED: ${AUTH_ENABLED:-true} + # Nur einschalten, wenn wirklich ein Proxy davorsteht — siehe + # .env.example. + TRUST_PROXY: ${TRUST_PROXY:-false} MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10} SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30} SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90} diff --git a/docs/sicherheit.md b/docs/sicherheit.md index 164b185..34a2096 100644 --- a/docs/sicherheit.md +++ b/docs/sicherheit.md @@ -231,6 +231,105 @@ gesetztes `X-Forwarded-Host` macht die Anfrage zu einer, die erst einen CORS-Vorabflug bestehen müsste, den die Anwendung nicht beantwortet. Das verbleibende Unbehagen ist das Vertrauensmodell insgesamt; dazu unten mehr. +### 15. Die Kontosperre konnte den Besitzer aussperren + +**Befund:** Acht Fehlversuche sperrten ein Konto für 15 Minuten — auch gegen +das **richtige** Passwort. Wer die Adresse eines Administrators kannte, +konnte die Sperre alle 15 Minuten erneuern und die Verwaltung praktisch +dauerhaft schließen. Ein Nachweis, dass der Anfragende der Kontoinhaber ist, +war dafür nicht nötig. + +**Behoben:** Aus der Sperre wurde eine **Wartezeit**. Die ersten vier +Fehlversuche bleiben folgenlos, danach verdoppelt sie sich bis zu einer +Minute: + +| Fehlversuche | Wartezeit | +| --- | --- | +| bis 4 | keine | +| 5 | 1 s | +| 7 | 4 s | +| 10 | 32 s | +| ab 11 | 60 s | + +Für den Besitzer heißt das im schlimmsten Fall eine Minute statt einer +Viertelstunde Aussperrung. Für jemanden, der Passwörter durchprobiert, einen +Versuch pro Minute — das ist die eigentliche Aufgabe der Bremse, und die +erfüllt sie weiterhin. + +Die Wartezeit wird **vor** der Passwortprüfung ausgewertet. Ein Versuch, der +ohnehin abgewiesen wird, kostet damit keine 128 MiB. + +### 16. Die Bremsen ließen sich über eine Kopfzeile umgehen + +**Befund:** Der Absender wurde aus `X-Forwarded-For` gelesen. Ohne +vorgeschalteten Proxy — und die mitgelieferte Compose-Konfiguration +veröffentlicht den Port direkt — wählt ihn jeder Aufrufer selbst und umgeht +damit Login- wie Feedback-Bremse. + +**Behoben:** Drei Stufen statt einer, in `src/lib/auth/throttle.ts`: + +1. **Je Absender** — nur noch, wenn `TRUST_PROXY=true` gesetzt ist. Ohne + diese Angabe wird die Kopfzeile gar nicht gelesen. Eine Bremse, die sich + mit einem selbst gewählten Wert umgehen lässt, ist keine; dann lieber + ehrlich keine. +2. **Je Konto** — die Wartezeit aus Punkt 15. Sie hängt an der Kennung des + Kontos, nicht an einer Kopfzeile, und ist deshalb nicht zu umgehen. +3. **Für die ganze Installation** — eine Obergrenze von 60 Passwortprüfungen + je Minute, und 120 Rückmeldungen je Minute. Das ist die Stufe, die den + Speicher schützt: Ohne sie könnte jemand mit genügend gleichzeitigen + Anfragen den Server allein über die Hash-Berechnung lahmlegen. + +Was bleibt: Die Zähler liegen im Arbeitsspeicher eines Prozesses. Bei +mehreren Instanzen bräuchte es einen gemeinsamen Zähler — siehe „Offen“. + +### 17. Skripte liefen ohne Content-Security-Policy + +**Befund:** Die Policy bestand aus `frame-ancestors 'none'`. Gegen +eingeschleusten Skriptcode wirkte sie nicht. + +**Behoben:** Eine vollständige Policy mit **Nonce je Anfrage**. Skripte +laufen nur, wenn sie den Nonce dieser einen Anfrage tragen oder von einem +Skript geladen wurden, das selbst einen hatte (`strict-dynamic`). Ein +Skript, das über eine Lücke in die Seite gerät, hat keinen — und läuft +nicht. + +Dazu `default-src 'self'`, `object-src 'none'`, `base-uri 'self'`, +`form-action 'self'`. `frame-src` nennt ausschließlich die beiden +Video-Anbieter, die der Typ „Video“ einbetten kann. + +**Zwei bewusste Lockerungen**, damit sie nicht als Versehen gelesen werden: + +- `style-src` erlaubt weiterhin `'unsafe-inline'`. Ein Nonce wirkt nur auf + `