diff --git a/.env.example b/.env.example index 7161f6e..b9f9d55 100644 --- a/.env.example +++ b/.env.example @@ -14,12 +14,26 @@ BASE_URL=http://localhost:3000 APP_PORT=3000 # Anmeldung mit Benutzern, Gruppen und Rollen. -# false — jeder, der die App erreicht, darf alles (Einzelplatz/LAN). # true — Anmeldung erforderlich. Beim ersten Aufruf legst du den ersten -# Administrator an. +# Administrator an. Das ist die Vorgabe, auch wenn die Zeile fehlt. +# false — jeder, der die App erreicht, darf alles: Verwaltung, Uploads, +# saemtliche Codes. Nur fuer Einzelplatz- oder LAN-Betrieb, und +# nur, wenn niemand Fremdes an den Rechner kommt. Der Container +# warnt beim Start deutlich, solange das so steht. # Bewusst hier und nicht in der Oberfläche: so kann man sich nicht aussperren. -# Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu. -AUTH_ENABLED=false +# 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 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 18c4ecc..7237a44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,12 @@ services: BASE_URL: ${BASE_URL:-http://localhost:3000} DATABASE_URL: ${DATABASE_URL} # Anmeldung mit Benutzern, Gruppen und Rollen. - AUTH_ENABLED: ${AUTH_ENABLED:-false} + # 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/docker/entrypoint.sh b/docker/entrypoint.sh index 6452bf1..564e918 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,5 +5,17 @@ set -e echo "→ Datenbankmigrationen werden angewendet …" node /app/node_modules/prisma/build/index.js migrate deploy +# Eine Anwendung ohne Anmeldung sieht genauso aus wie eine mit — deshalb +# muss sie es sagen. Wer das hier im Protokoll sieht und nicht erwartet hat, +# soll sofort wissen, woran er ist. +if [ "$(printf '%s' "${AUTH_ENABLED:-}" | tr '[:upper:]' '[:lower:]')" = "false" ]; then + echo "" >&2 + echo " !! ACHTUNG: AUTH_ENABLED=false — die Anwendung laeuft OHNE Anmeldung." >&2 + echo " !! Jeder, der sie erreicht, darf alles: Verwaltung, Uploads, saemtliche" >&2 + echo " !! Codes anlegen, aendern und loeschen. Das ist nur fuer Einzelplatz-" >&2 + echo " !! oder LAN-Betrieb gedacht und gehoert nicht ins offene Netz." >&2 + echo "" >&2 +fi + echo "→ Anwendung wird gestartet …" exec "$@" diff --git a/docs/sicherheit.md b/docs/sicherheit.md index 366ab25..34a2096 100644 --- a/docs/sicherheit.md +++ b/docs/sicherheit.md @@ -1,7 +1,9 @@ # Anmeldung und Sitzungen — Durchsicht nach OWASP Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen -die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026. +die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026, ergänzt am 24.08.2026 +um die Befunde einer externen Codeprüfung (Punkte 11 bis 14 und die drei +offenen Punkte am Ende). Geprüft gegen: @@ -163,6 +165,171 @@ Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt. **Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung) wird beiläufig aufgeräumt. +### 11. OIDC wertete eine unbestätigte E-Mail als bestätigt + +**Befund:** Der schwerste der externen Prüfung. Findet die OIDC-Rückkehr ein +lokales Konto mit derselben E-Mail-Adresse, hängt sie die Identität des +Anbieters daran — aber nur, wenn die Adresse **bestätigt** ist. Genau das +prüfte der Code auch, nur war die Bestätigung falsch berechnet: + +```ts +emailVerified: payload.email_verified !== false +``` + +Damit galt nicht nur `true` als bestätigt, sondern auch ein **fehlender +Claim**, `null`, `0` und jede Zeichenkette. Ein Anbieter, der +`email_verified` gar nicht mitschickt — das ist erlaubt und kommt vor —, +hätte die Verknüpfung geöffnet. Wer sich dort eine fremde Adresse eintragen +kann, hätte damit das hiesige Konto übernommen, im schlimmsten Fall das eines +Administrators. + +**Behoben:** Nur ein ausdrückliches `true` zählt. Alles andere führt zur +bisherigen Meldung „der Anbieter hat sie nicht bestätigt“, und die +Verknüpfung unterbleibt. + +**Was das nicht war:** kein Angriff ohne Zugriff auf den Anbieter — das +ID-Token muss weiterhin gültig signiert sein und Aussteller, Empfänger und +Nonce bestehen. + +### 12. Eine fehlende Angabe schaltete die Anmeldung ab + +**Befund:** `isAuthEnabled()` verlangte den Wert `true`; alles andere hieß +„aus“. Eine vergessene oder falsch geschriebene Variable ergab damit eine +Anwendung, in der jeder Besucher alles darf — und nichts sah danach aus. Die +Compose-Vorgabe stand zusätzlich auf `false`. + +**Behoben:** Umgedreht. Abgeschaltet wird nur, wer ausdrücklich `false` +setzt; ein fehlender Wert bedeutet „an“. Die Vorgaben in `docker-compose.yml` +und `.env.example` stehen auf `true`. Steht die Anmeldung aus, schreibt der +Container beim Start eine mehrzeilige Warnung ins Protokoll. + +Der offene Betrieb bleibt möglich — er ist für Einzelplatz und LAN gedacht +und dokumentiert. Er ist nur nicht mehr das, was man versehentlich bekommt. + +### 13. Wettlauf beim ersten Administrator über OIDC + +**Befund:** Der OIDC-Pfad las `user.count()` und legte danach an. Zwei +gleichzeitige erste Anmeldungen konnten sich beide für die erste halten und +beide als Instanz-Administrator entstehen. Die Passwort-Ersteinrichtung +sicherte denselben Ablauf längst mit einer serialisierbaren Transaktion. + +**Behoben:** Derselbe Schutz auch hier — Zählen und Anlegen liegen in einer +Transaktion mit `isolationLevel: "Serializable"`. + +### 14. Die Herkunftsprüfung verglich das Schema nicht + +**Befund:** `isSameOrigin()` verglich nur den Host. `http://example.com` galt +damit als eigen, obwohl die Anwendung unter `https://example.com` läuft. + +**Behoben:** Das Schema wird mitverglichen, und es kommt aus `BASE_URL` — der +einen Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt. + +**Zur Einordnung:** Die Prüfung vergleicht weiterhin gegen den `Host`- bzw. +`X-Forwarded-Host`-Header der Anfrage. Aus einem Browser heraus ist das nicht +zu missbrauchen — `Host` darf Skriptcode nicht setzen, und ein selbst +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 + `