Files
betreuungs-cockpit/docs/SICHERHEIT.md
T
Florian Wilde e48eda4332 Irrefuehrende Sicherheitszusagen entfernen, OWASP-Durchgang festhalten
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
2026-08-24 09:53:43 +02:00

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.