Bremsen, Sperren und CSP: die offenen Punkte schliessen
S3: Aus der Kontosperre wird eine Wartezeit. Die alte Sperre wies auch das richtige Passwort ab — wer die Adresse eines Administrators kannte, konnte ihn alle 15 Minuten neu aussperren. Jetzt bleiben vier Fehlversuche folgenlos, danach verdoppelt sich die Wartezeit bis zu einer Minute. Der Besitzer kommt spaetestens nach einer Minute hinein, ein Fremder bekommt einen Versuch pro Minute. Ausgewertet wird sie vor der Passwortpruefung, damit ein ohnehin abgewiesener Versuch keine 128 MiB kostet. S4: Drei Stufen statt einer. Die Bremse je Absender liest X-Forwarded-For nur noch mit TRUST_PROXY=true — ohne Proxy waehlt den Wert jeder Aufrufer selbst, und eine so umgehbare Bremse ist eine Behauptung. Dazu die Wartezeit je Konto, die an der Datenbank haengt, und eine Obergrenze fuer die ganze Installation: 60 Passwortpruefungen und 120 Rueckmeldungen je Minute. Die letzte Stufe ist die einzige, die sich nicht ueber Kopfzeilen umgehen laesst, und sie schuetzt den Speicher. CSP: Eine vollstaendige Policy mit Nonce je Anfrage, strict-dynamic, default-src self, object-src none, base-uri und form-action self. Skripte ohne den Nonce dieser Anfrage laufen nicht mehr. Zwei Lockerungen bleiben und stehen begruendet in der Doku: style-src erlaubt unsafe-inline, weil ein Nonce keine style-Attribute deckt, und media-src erlaubt https, weil der Typ Video ausdruecklich Direktlinks annimmt. Protokoll: Sicherheitsereignisse als JSON auf die Standardausgabe — Fehlversuche, OIDC-Verknuepfungen samt abgelehnten, Vergabe von Administratorrechten. Ohne Passwoerter, Token und Adressen. Refs #20
This commit is contained in:
+107
-27
@@ -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
|
||||
`<style>`-Blöcke, nicht auf `style="…"` an einzelnen Elementen; die
|
||||
Oberfläche benutzt solche Attribute an rund einem Dutzend Stellen. Über
|
||||
ein Stilattribut lässt sich kein Code ausführen, der Gewinn wäre gering.
|
||||
- `media-src` erlaubt jede `https:`-Quelle. Der Typ „Video“ nimmt
|
||||
ausdrücklich auch einen Direktlink auf eine Videodatei entgegen, und wo
|
||||
die liegt, weiß nur der, der ihn einträgt.
|
||||
|
||||
`upgrade-insecure-requests` steht nur, wenn `BASE_URL` auf `https` zeigt —
|
||||
auf einer bewusst über HTTP betriebenen Installation würde die Regel die
|
||||
eigenen Ressourcen hochstufen und alles zerlegen.
|
||||
|
||||
### 18. Sicherheitsereignisse standen nirgends
|
||||
|
||||
**Befund:** Fehlversuche, OIDC-Verknüpfungen und die Vergabe von
|
||||
Administratorrechten hinterließen keine Spur.
|
||||
|
||||
**Behoben:** `src/lib/auth/protokoll.ts` schreibt je Ereignis eine Zeile als
|
||||
JSON auf die Standardausgabe — damit landet sie in `docker compose logs` und
|
||||
lässt sich von dort weiterreichen, ohne dass die Anwendung ein Werkzeug
|
||||
dafür kennen muss.
|
||||
|
||||
Protokolliert werden: fehlgeschlagene Anmeldungen samt Zahl der Versuche und
|
||||
Wartezeit, abgelehnte OIDC-Verknüpfungen mit Grund, erfolgte Verknüpfungen
|
||||
und jede Vergabe von Administratorrechten.
|
||||
|
||||
**Was ausdrücklich nicht hineingehört:** Passwörter, Sitzungstoken und
|
||||
vollständige E-Mail-Adressen. Konten stehen mit ihrer Id da. Ein Protokoll
|
||||
wird kopiert und verschickt und ist selten so gut geschützt wie die
|
||||
Datenbank.
|
||||
|
||||
---
|
||||
|
||||
## Was schon vorher stimmte
|
||||
@@ -286,34 +385,15 @@ selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
|
||||
|
||||
Bewusst nicht umgesetzt, mit Begründung.
|
||||
|
||||
**Die Kontosperre kann den Besitzer aussperren.** Acht falsche Passwörter
|
||||
sperren ein Konto für 15 Minuten, ohne dass der Anfragende belegen muss, wer
|
||||
er ist. Wer die Adresse des Administrators kennt, kann die Sperre alle 15
|
||||
Minuten erneuern und die Verwaltung praktisch dauerhaft blockieren.
|
||||
**Die Zähler gelten je Prozess.** Bremsen und Wartezeiten liegen im
|
||||
Arbeitsspeicher und sind nach einem Neustart weg. Bei **einer** Instanz — dem
|
||||
Regelfall dieser Anwendung — ist das verschmerzbar: Die Wartezeit je Konto
|
||||
steht in der Datenbank und übersteht den Neustart, nur die Zähler je Absender
|
||||
und die Obergrenze der Instanz beginnen von vorn.
|
||||
|
||||
Der naheliegende Ausweg — auch während der Sperre das Passwort prüfen und ein
|
||||
richtiges durchlassen — wurde geprüft und **verworfen**: Eine Prüfung kostet
|
||||
hier rund **128 MiB Arbeitsspeicher** (scrypt mit N = 2¹⁷). Wer sie beliebig
|
||||
oft auslösen darf, legt damit den Server lahm. Die Sperre gegen ein Konto
|
||||
einzutauschen wäre also eine Sperre gegen alle.
|
||||
|
||||
Was es stattdessen braucht, ist ein eigener Entwurf: eine ansteigende
|
||||
Verzögerung, die keinen Speicher hält, oder eine billige Vorprüfung vor dem
|
||||
teuren Hash — zusammen mit dem nächsten Punkt zu entscheiden.
|
||||
|
||||
**Die Bremsen lassen sich über `X-Forwarded-For` umgehen.** Der Absender wird
|
||||
aus diesem Header gelesen. Steht kein Proxy davor — und die mitgelieferte
|
||||
Compose-Konfiguration veröffentlicht den Port direkt —, wählt ihn jeder
|
||||
Aufrufer selbst und umgeht damit Login-Bremse und Feedback-Bremse. Die Zähler
|
||||
liegen zudem im Arbeitsspeicher eines Prozesses und sind nach einem Neustart
|
||||
weg.
|
||||
|
||||
Die Anwendung läuft in Next.js-Routen und kommt an die Adresse der
|
||||
Gegenstelle gar nicht heran — „im Zweifel die Socket-Adresse nehmen“ ist hier
|
||||
also keine Möglichkeit. Nötig ist eine ausdrückliche Einstellung, welchem
|
||||
Proxy zu trauen ist, und ein gemeinsamer Zähler, wenn mehr als eine Instanz
|
||||
läuft. Beides gehört mit dem Punkt davor in einen Entwurf und nicht in eine
|
||||
schnelle Änderung.
|
||||
Wer mehrere Instanzen hinter einem Lastverteiler betreibt, braucht einen
|
||||
gemeinsamen, atomaren Zähler — üblicherweise Redis. Das ist ein weiterer
|
||||
Dienst und lohnt sich erst, wenn es die zweite Instanz wirklich gibt.
|
||||
|
||||
**Prisma hängt an einer Advisory.** `npm audit --omit=dev` meldet
|
||||
[GHSA-ggr8-5vv4-36mx](https://github.com/advisories/GHSA-ggr8-5vv4-36mx) in
|
||||
|
||||
Reference in New Issue
Block a user