From e48eda43322f5c6d3002e953f6f39718e267d4e2 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 24 Aug 2026 09:53:43 +0200 Subject: [PATCH] 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 -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 --- docs/SICHERHEIT.md | 168 ++++++++++++++++++++++++++++++++++++++ src/lib/speicher/datei.ts | 14 +++- src/middleware.ts | 19 ++++- 3 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 docs/SICHERHEIT.md diff --git a/docs/SICHERHEIT.md b/docs/SICHERHEIT.md new file mode 100644 index 0000000..76d6ab1 --- /dev/null +++ b/docs/SICHERHEIT.md @@ -0,0 +1,168 @@ +# 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....`, 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` / `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 ``-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. diff --git a/src/lib/speicher/datei.ts b/src/lib/speicher/datei.ts index db9cb16..415cdd3 100644 --- a/src/lib/speicher/datei.ts +++ b/src/lib/speicher/datei.ts @@ -34,8 +34,18 @@ function wurzel(): string { /** * Baut den Dateipfad - und prueft danach noch einmal, dass er wirklich * unterhalb des Ablageverzeichnisses liegt. Der Schluessel ist zwar bereits - * streng geprueft, aber diese zweite Sperre kostet nichts und faengt auch - * Symlink-Tricks und Windows-Eigenheiten ab. + * streng geprueft, aber diese zweite Sperre kostet nichts. + * + * Was sie NICHT abfaengt, obwohl hier lange das Gegenteil stand: Symlinks. + * resolve() rechnet nur mit Zeichenketten und folgt keinem Link - dafuer + * braeuchte es realpath(). Ein untergeschobener Symlink im Ablageverzeichnis + * wuerde also nicht auffallen. + * + * Nachgeruestet wird das trotzdem nicht, und zwar aus einem Grund und nicht + * aus Bequemlichkeit: Wer dort einen Symlink anlegen kann, hat bereits + * Schreibrechte im Datenverzeichnis des Servers - dann ist der Umweg ueber + * einen Link die Sorge, die am wenigsten draengt. Der Satz musste + * verschwinden, weil er eine Sicherheit versprach, die es nie gab. */ function pfadFuer(schluessel: string): string { const geprueft = schluesselErfordern(schluessel) diff --git a/src/middleware.ts b/src/middleware.ts index 1d2c941..03661cb 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -30,8 +30,21 @@ import { NextResponse, type NextRequest } from 'next/server' * Data-URI eingebettet, statt sie ueber einen Fremddienst zu holen. */ -/** Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen. */ -const OEFFENTLICHE_PFADE = [ +/** + * Seiten, die auch ohne Anmeldung sinnvoll im Verlauf stehen duerfen. + * + * ACHTUNG - was diese Liste NICHT ist: eine Zugriffskontrolle. Frueher + * stand hier "Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen", + * und das war schlicht falsch: Die Middleware prueft keine Anmeldung, sie + * setzt Kopfzeilen. Ausgewertet wird die Liste einzig fuer die Frage, ob + * "Cache-Control: no-store" gesetzt wird. + * + * Der Irrtum waere teuer geworden: Wer den Satz glaubt, verzichtet an einer + * neuen Seite auf erfordereBenutzer() - "die Middleware faengt das ja ab". + * Sie faengt nichts ab. Der Zugriffsschutz sitzt in + * src/lib/auth/sitzung.ts, und nur dort. + */ +const OHNE_CACHE_SPERRE = [ '/anmelden', '/ersteinrichtung', '/datenschutz', @@ -79,7 +92,7 @@ export function middleware(anfrage: NextRequest) { // Verhindert, dass ein Bericht oder Beleg im Verlauf des Browsers // zwischengespeichert wird. - if (!OEFFENTLICHE_PFADE.some((pfad) => anfrage.nextUrl.pathname.startsWith(pfad))) { + if (!OHNE_CACHE_SPERRE.some((pfad) => anfrage.nextUrl.pathname.startsWith(pfad))) { antwort.headers.set('Cache-Control', 'no-store, must-revalidate') }