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
This commit is contained in:
@@ -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.<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.
|
||||||
@@ -34,8 +34,18 @@ function wurzel(): string {
|
|||||||
/**
|
/**
|
||||||
* Baut den Dateipfad - und prueft danach noch einmal, dass er wirklich
|
* Baut den Dateipfad - und prueft danach noch einmal, dass er wirklich
|
||||||
* unterhalb des Ablageverzeichnisses liegt. Der Schluessel ist zwar bereits
|
* unterhalb des Ablageverzeichnisses liegt. Der Schluessel ist zwar bereits
|
||||||
* streng geprueft, aber diese zweite Sperre kostet nichts und faengt auch
|
* streng geprueft, aber diese zweite Sperre kostet nichts.
|
||||||
* Symlink-Tricks und Windows-Eigenheiten ab.
|
*
|
||||||
|
* 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 {
|
function pfadFuer(schluessel: string): string {
|
||||||
const geprueft = schluesselErfordern(schluessel)
|
const geprueft = schluesselErfordern(schluessel)
|
||||||
|
|||||||
+16
-3
@@ -30,8 +30,21 @@ import { NextResponse, type NextRequest } from 'next/server'
|
|||||||
* Data-URI eingebettet, statt sie ueber einen Fremddienst zu holen.
|
* 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',
|
'/anmelden',
|
||||||
'/ersteinrichtung',
|
'/ersteinrichtung',
|
||||||
'/datenschutz',
|
'/datenschutz',
|
||||||
@@ -79,7 +92,7 @@ export function middleware(anfrage: NextRequest) {
|
|||||||
|
|
||||||
// Verhindert, dass ein Bericht oder Beleg im Verlauf des Browsers
|
// Verhindert, dass ein Bericht oder Beleg im Verlauf des Browsers
|
||||||
// zwischengespeichert wird.
|
// 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')
|
antwort.headers.set('Cache-Control', 'no-store, must-revalidate')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user