Zwei Kommentare versprachen Schutz, den es nicht gab. Das ist schlimmer als eine fehlende Zusage, weil man sich darauf verlaesst: - In middleware.ts stand "Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen". Die Middleware prueft keine Anmeldung, sie setzt Kopfzeilen; die Liste steuert allein den Cache-Control-Kopf. Wer den Satz glaubt, verzichtet an einer neuen Seite auf erfordereBenutzer(). Die Liste heisst jetzt OHNE_CACHE_SPERRE und sagt selbst, was sie ist. - In datei.ts hiess es, die Pfadpruefung fange "Symlink-Tricks" ab. resolve() rechnet mit Zeichenketten und folgt keinem Link - dafuer braeuchte es realpath(). Nachgeruestet wird es nicht: Wer dort einen Symlink anlegen kann, hat schon Schreibrechte im Datenverzeichnis. Der Satz musste trotzdem weg. Dazu docs/SICHERHEIT.md: Bedrohungsmodell, der Durchgang entlang der OWASP Top 10 - und ein eigener Abschnitt darueber, was ausdruecklich NICHT geschuetzt ist. Aus dem OWASP-Durchgang kamen keine weiteren Funde. Zwei Stellen sahen danach aus und waren es nicht: Prisma.join() in suche.ts fuegt nur bereits parametrisierte Fragmente zusammen, und der Suchausschnitt mit den <mark>-Markern aus ts_headline wird zerlegt und als React-Kinder gerendert, nicht als HTML - obwohl die Quelle der OCR-Text hochgeladener Dokumente ist. Refs #38
7.2 KiB
Sicherheit
Was in dieser Anwendung gegen welche Bedrohung steht — und was ausdrücklich nicht. Der zweite Teil ist der wichtigere: Eine Zusage, die nicht gilt, ist gefährlicher als eine fehlende, weil man sich auf sie verlässt.
Stand: v1.11.0. Die Fassung entstand beim Abarbeiten von Issue #38 (Sicherheitsprüfung durch ein zweites Modell) und dem eigenen Durchgang entlang der OWASP Top 10 (2021).
Das Bedrohungsmodell
Eine ehrenamtliche rechtliche Betreuung. Ein Benutzer, eine betreute Person, ein kleiner Server hinter einem Reverse Proxy. In der Datenbank liegen Gesundheitsdaten (Art. 9 DSGVO), Kontobewegungen und Vermögensverzeichnisse.
Nach Wahrscheinlichkeit geordnet:
| Was | Wie wahrscheinlich | Was dagegen steht |
|---|---|---|
| Offene Sitzung an einem unbeaufsichtigten Rechner | hoch | Abmeldung nach 30 min Leerlauf; erneute Bestätigung vor Export, Löschen, Benutzer- und Passkey-Verwaltung |
| Verlorenes oder gestohlenes Gerät | mittel | Zweiter Faktor ist Pflicht; Passkeys verlangen Nutzerverifikation |
| Kopiertes Backup, falsch gerechtetes Verzeichnis | mittel | Feldverschlüsselung (AES-256-GCM), Dateien im Umschlag, Schlüssel nur in der Umgebung |
| Zugangsdaten zum Objektspeicher abhandengekommen | mittel | Objekte liegen verschlüsselt, an ihren Ablageort gebunden |
| Raten von Passwörtern über das Formular | mittel | Anmeldesperre nach E-Mail und Anschluss, Argon2id |
| Einbruch in den Server selbst | gering | Kaum etwas — wer dort Wurzelrechte hat, hat die Schlüssel |
Der letzte Punkt ist keine Nachlässigkeit, sondern eine Grenze: Die Anwendung braucht die Schlüssel zur Laufzeit. Wer den Prozess kontrolliert, kommt an sie heran. Dagegen hilft Betriebssicherheit, kein Anwendungscode.
Was ausdrücklich nicht geschützt ist
- Der Server selbst. Siehe oben.
- Metadaten in der Datenbank. Verschlüsselt sind einzelne Felder — Diagnosen, IBANs, Verwendungszwecke, Notizen. Dass es einen Vorgang „Betreuungsgericht" gibt und wann er angelegt wurde, steht im Klartext. Sonst ließe sich nichts mehr sortieren oder filtern.
- Symlinks im Ablageverzeichnis.
resolve()folgt keinen Links. Wer dort einen anlegen kann, hat bereits Schreibrechte im Datenverzeichnis — dann ist der Umweg über einen Link die geringste Sorge. Bis v1.10.2 stand im Code das Gegenteil; der Satz ist entfernt. - Die Middleware prüft keine Anmeldung. Sie setzt Kopfzeilen, mehr
nicht. Der Zugriffsschutz sitzt in
src/lib/auth/sitzung.ts, und nur dort. Bis v1.10.2 behauptete ein Kommentar das Gegenteil. - Verfügbarkeit. Es gibt keinen Schutz gegen jemanden, der den Server mit Anfragen überzieht. Bei einem Nutzer hinter einem Proxy ist das die Aufgabe des Proxys.
Der Durchgang entlang der OWASP Top 10 (2021)
A01 Broken Access Control
Jede Server Action und jede Seite geht durch erfordereZugang(), das
zeilenweise gegen die betreuteId prüft — nie nur im UI ausgeblendet.
Fremdschlüssel aus Formularen werden über src/lib/auth/verweise.ts
geprüft; ein verändertes Auswahlfeld kann keine fremde Akte verknüpfen
(e2e-Test fremdakte.spec.ts).
Weiterleitungsziele laufen durch sicheresZiel() — kein offener Redirect.
Behoben in dieser Runde: Die MFA-Pflicht galt nur für Seiten, nicht
für Server Actions und Route Handler. Sie steckt jetzt in
erfordereBenutzer() selbst. tests/mfa-grenze.test.ts liest den
Quelltext und schlägt an, sobald eine weitere Umgehung dazukommt.
A02 Cryptographic Failures
- Felder: AES-256-GCM, Umschlag
bc1.<version>.<iv>.<tag>.<ct>, Feldname als Zusatzdaten — ein Geheimtext lässt sich nicht von einem Feld in ein anderes kopieren. - Dateien: AES-256-GCM, Umschlag
BCD1, seit v1.11.0 beide Treiber, mit dem Ablageschlüssel als Zusatzdaten. - Passwörter: Argon2id, Parameter im Hash, Nachziehen beim Anmelden.
- Schlüsselrotation über
ENC_KEY_V<n>/ENC_KEY_AKTIV; alte Schlüssel bleiben zum Entschlüsseln stehen. - Alle Geheimnisvergleiche laufen über
timingSafeEqual— geprüft für Reauth-Cookie, Zugriffstoken, ICS-Signatur, Wartungsschlüssel und das Einrichtungsgeheimnis.
Behoben in dieser Runde: Der Objektspeicher verließ sich auf
ServerSideEncryption: 'AES256' — den Schlüssel des Anbieters.
A03 Injection
Prisma parametrisiert. Die fünf Rohabfragen benutzen ausnahmslos
Prisma.sql-Templates; Prisma.join() in suche.ts fügt nur bereits
parametrisierte Fragmente zusammen und ist damit unbedenklich.
Zu XSS: ts_headline liefert <mark>-Marker im Suchausschnitt, und die
Quelle ist der OCR-Text hochgeladener Dokumente. Gerendert wird er in
dokument-liste.tsx aber nicht als HTML, sondern zerlegt und als React-
Kinder ausgegeben — also escaped. Das einzige
dangerouslySetInnerHTML der Anwendung setzt eine feste Konstante für die
Themenumschaltung. Kein Fund.
A04 Insecure Design
Erneute Bestätigung vor den folgenschweren Handlungen. Anmeldesperre nach zwei Dimensionen. Löschen ist ein Papierkorb mit 30 Tagen Frist.
Behoben in dieser Runde: Passkeys ließen sich ohne erneute Bestätigung anlegen und entfernen — ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel überlebt.
A05 Security Misconfiguration
CSP mit Nonce pro Anfrage und strict-dynamic im Produktivbetrieb; dazu
X-Content-Type-Options, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Permissions-Policy, HSTS mit zwei Jahren.
poweredByHeader aus.
Behoben in dieser Runde: trustHost stand fest auf true, obwohl
AUTH_URL gesetzt ist. X-Forwarded-For wurde ohne Proxy geglaubt und
aus dem falschen Ende gelesen.
A06 Vulnerable and Outdated Components
npm audit: 0 Schwachstellen (vorher 32, davon 5 kritisch).
A07 Identification and Authentication Failures
Passwort + TOTP in einem Schritt, kein Zwischenzustand. Passkeys mit
erzwungener Nutzerverifikation zählen als zwei Faktoren.
sitzungsGeneration macht alle Sitzungen auf einen Schlag ungültig.
Antwortzeiten sind angeglichen, damit sich keine E-Mail-Adressen abfragen
lassen.
Behoben in dieser Runde: TOTP-Codes galten mehrfach; die Anmeldesperre verlängerte sich selbst; die Ersteinrichtung stand offen und war nicht atomar.
A08 Software and Data Integrity Failures
Zu jedem abgelegten Dokument steht eine SHA-256-Prüfsumme über den Klartext in der Datenbank — der Nachweis der Unverändertheit gegenüber dem Gericht. Der Umschlag ist authentifiziert (GCM), Veränderungen fallen beim Lesen auf.
A09 Security Logging and Monitoring Failures
AuditLog hält Anmeldungen, Fehlversuche, Exporte, Löschungen und
Passkey-Änderungen fest. Anmeldeversuche stehen zusätzlich in
login_versuch und werden nach 30 Tagen aufgeräumt.
Behoben in dieser Runde: Während einer Sperre entstand pro Anfrage eine Zeile — die Tabelle ließ sich vollschreiben. Jetzt eine je Sperre.
A10 Server-Side Request Forgery
Die Anwendung ruft nichts von außen ab: kein fetch, keine
HTTP-Bibliothek, keine Webhooks, keine URL-Vorschauen. Ausgehend geht nur
SMTP an den konfigurierten Server. Keine Angriffsfläche.
Wenn etwas gefunden wird
Kein öffentliches Issue. Der Weg führt über das Dashboard; dort wird entschieden, ob und wie es hier landet.