Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39

Merged
florian.wilde merged 10 commits from fix/38-sicherheitsaudit into main 2026-08-24 10:35:20 +00:00
Owner

Arbeitet die Punkte aus der Sicherheitsprüfung ab und ergänzt einen
eigenen Durchgang entlang der OWASP Top 10.

Closes #38

Vor dem Update: zwei neue .env-Zeilen

ERSTEINRICHTUNG_GEHEIMNIS=<zufällig, npm run schluessel erzeugt einen>
VERTRAUE_PROXY=true

Beide sind nicht zwingend, beide haben Folgen, wenn sie fehlen:

  • Ohne ERSTEINRICHTUNG_GEHEIMNIS läuft alles weiter, nur der erste
    Zugang ließe sich nicht anlegen. Auf einer bestehenden Installation ist
    das richtig so; bei einer Neuinstallation braucht es den Wert.
  • Ohne VERTRAUE_PROXY=true zählt die Anmeldesperre nur noch nach
    E-Mail-Adresse, nicht mehr zusätzlich nach Anschluss. Beim ersten Mal
    steht eine erklärende Zeile im Log.

Nach dem Update, nur mit Objektspeicher

npm run speicher:verschluesseln              # erst schauen
npm run speicher:verschluesseln -- --anwenden

Bereits abgelegte Dateien liegen unverschlüsselt, bis sie einmal
umgeschrieben werden. Lesen funktioniert durchgehend, der Lauf lässt sich
abbrechen und wiederholen.

Was geändert wurde

Drei Zusagen, die nicht galten

  • Der zweite Faktor war im Kopf von auth.ts als Pflicht beschrieben,
    durchgesetzt aber nur im App-Layout. Server Actions und Downloads liefen
    daran vorbei. Sitzt jetzt in erfordereBenutzer(); ein Test liest den
    Quelltext und schlägt an, sobald eine Umgehung dazukommt.
  • middleware.ts: „Ohne Authentifizierungs-Cookie gar nicht erst
    weiterreichen" — die Middleware prüft keine Anmeldung.
  • datei.ts: die Pfadprüfung fange „Symlink-Tricks" ab — resolve()
    folgt keinem Link.

Anmeldung

  • Die Sperre verlängerte sich bei jedem abgewiesenen Versuch um die volle
    Dauer. Wer alle zehn Minuten ein falsches Passwort an eine fremde
    Adresse schickte, sperrte deren Inhaber dauerhaft aus — ohne das
    Passwort zu kennen. Dazu eine Tabellenzeile je Anfrage.
  • TOTP-Codes galten bis zu 90 Sekunden mehrfach.
  • X-Forwarded-For wurde aus dem ersten Eintrag gelesen, den der
    Aufrufer selbst schreibt, und auch ohne Proxy geglaubt.
  • trustHost stand fest auf true, obwohl AUTH_URL gesetzt ist.
  • Passkeys ließen sich ohne erneute Bestätigung anlegen und entfernen.
  • Die Ersteinrichtung stand zwischen erstem Start und erstem Zugang offen
    und war nicht atomar.

Daten

  • Der Objektspeicher verließ sich auf ServerSideEncryption: 'AES256' —
    den Schlüssel des Anbieters. Jetzt derselbe Umschlag wie beim
    Dateitreiber, zusätzlich an den Ablageort gebunden. Presigned URLs
    entfallen dadurch; Downloads laufen über die eigene Route, die die
    Berechtigung prüft und protokolliert.
  • PDF-Ströme wurden ohne Obergrenze ausgepackt (Dekompressionsbombe), und
    beim Rendern bestimmte die im Dokument angegebene Seitengröße die
    Bildgröße.

Abhängigkeiten

32 Schwachstellen, davon 5 kritische → 0. Ohne Sprung auf Next.js 16:
next, next-auth und @auth/core waren nur durchgereicht markiert; ein
eigenes Advisory hatten allein nodemailer, postcss und sharp.

Geprüft

tsc --noEmit sauber
Lint sauber
Unit-Tests 709
E2E 95 bestanden, 6 übersprungen (viewport-bedingt)
npm audit 0

Die neuen Tests sind gegen Mutationen geprüft — zweimal war der erste
Anlauf wertlos und blieb bei entferntem Schutz grün; beide wurden ersetzt.
Dazu ein Trockenlauf des Migrationsskripts über 120 Dateien und eine Probe
mit einer Klartextdatei, die danach verschlüsselt auf der Platte liegt und
sich korrekt zurücklesen lässt.

Zum Testen

git switch fix/38-sicherheitsaudit
npm install
npm run db:deploy
npm run dev
  1. Anmelden — muss normal funktionieren.
  2. Denselben TOTP-Code sofort ein zweites Mal eingeben → wird abgewiesen.
  3. Unter Mein Konto einen Passkey anlegen wollen → fragt vorher nach
    Passwort und Code.
  4. Ein Dokument öffnen → lädt über die Anwendung, nicht mehr direkt vom
    Speicher.
Arbeitet die Punkte aus der Sicherheitsprüfung ab und ergänzt einen eigenen Durchgang entlang der OWASP Top 10. Closes #38 ## Vor dem Update: zwei neue `.env`-Zeilen ```bash ERSTEINRICHTUNG_GEHEIMNIS=<zufällig, npm run schluessel erzeugt einen> VERTRAUE_PROXY=true ``` Beide sind nicht zwingend, beide haben Folgen, wenn sie fehlen: - Ohne `ERSTEINRICHTUNG_GEHEIMNIS` läuft alles weiter, nur der **erste** Zugang ließe sich nicht anlegen. Auf einer bestehenden Installation ist das richtig so; bei einer Neuinstallation braucht es den Wert. - Ohne `VERTRAUE_PROXY=true` zählt die Anmeldesperre nur noch nach E-Mail-Adresse, nicht mehr zusätzlich nach Anschluss. Beim ersten Mal steht eine erklärende Zeile im Log. ## Nach dem Update, nur mit Objektspeicher ```bash npm run speicher:verschluesseln # erst schauen npm run speicher:verschluesseln -- --anwenden ``` Bereits abgelegte Dateien liegen unverschlüsselt, bis sie einmal umgeschrieben werden. Lesen funktioniert durchgehend, der Lauf lässt sich abbrechen und wiederholen. ## Was geändert wurde **Drei Zusagen, die nicht galten** - Der zweite Faktor war im Kopf von `auth.ts` als Pflicht beschrieben, durchgesetzt aber nur im App-Layout. Server Actions und Downloads liefen daran vorbei. Sitzt jetzt in `erfordereBenutzer()`; ein Test liest den Quelltext und schlägt an, sobald eine Umgehung dazukommt. - `middleware.ts`: „Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen" — die Middleware prüft keine Anmeldung. - `datei.ts`: die Pfadprüfung fange „Symlink-Tricks" ab — `resolve()` folgt keinem Link. **Anmeldung** - Die Sperre verlängerte sich bei jedem abgewiesenen Versuch um die volle Dauer. Wer alle zehn Minuten ein falsches Passwort an eine fremde Adresse schickte, sperrte deren Inhaber dauerhaft aus — ohne das Passwort zu kennen. Dazu eine Tabellenzeile je Anfrage. - TOTP-Codes galten bis zu 90 Sekunden mehrfach. - `X-Forwarded-For` wurde aus dem **ersten** Eintrag gelesen, den der Aufrufer selbst schreibt, und auch ohne Proxy geglaubt. - `trustHost` stand fest auf `true`, obwohl `AUTH_URL` gesetzt ist. - Passkeys ließen sich ohne erneute Bestätigung anlegen und entfernen. - Die Ersteinrichtung stand zwischen erstem Start und erstem Zugang offen und war nicht atomar. **Daten** - Der Objektspeicher verließ sich auf `ServerSideEncryption: 'AES256'` — den Schlüssel des Anbieters. Jetzt derselbe Umschlag wie beim Dateitreiber, zusätzlich an den Ablageort gebunden. Presigned URLs entfallen dadurch; Downloads laufen über die eigene Route, die die Berechtigung prüft und protokolliert. - PDF-Ströme wurden ohne Obergrenze ausgepackt (Dekompressionsbombe), und beim Rendern bestimmte die im Dokument angegebene Seitengröße die Bildgröße. **Abhängigkeiten** 32 Schwachstellen, davon 5 kritische → 0. Ohne Sprung auf Next.js 16: `next`, `next-auth` und `@auth/core` waren nur durchgereicht markiert; ein eigenes Advisory hatten allein `nodemailer`, `postcss` und `sharp`. ## Geprüft | | | |---|---| | `tsc --noEmit` | sauber | | Lint | sauber | | Unit-Tests | 709 | | E2E | 95 bestanden, 6 übersprungen (viewport-bedingt) | | `npm audit` | 0 | Die neuen Tests sind gegen Mutationen geprüft — zweimal war der erste Anlauf wertlos und blieb bei entferntem Schutz grün; beide wurden ersetzt. Dazu ein Trockenlauf des Migrationsskripts über 120 Dateien und eine Probe mit einer Klartextdatei, die danach verschlüsselt auf der Platte liegt und sich korrekt zurücklesen lässt. ## Zum Testen ```bash git switch fix/38-sicherheitsaudit npm install npm run db:deploy npm run dev ``` 1. Anmelden — muss normal funktionieren. 2. Denselben TOTP-Code sofort ein zweites Mal eingeben → wird abgewiesen. 3. Unter *Mein Konto* einen Passkey anlegen wollen → fragt vorher nach Passwort und Code. 4. Ein Dokument öffnen → lädt über die Anwendung, nicht mehr direkt vom Speicher.
florian.wilde added 10 commits 2026-08-24 10:16:17 +00:00
npm audit meldete 32 Schwachstellen, davon 5 kritische. Der Vorschlag
des Werkzeugs war ein Sprung auf Next.js 16 - noetig war er nicht: next,
next-auth und @auth/core waren nur durchgereicht markiert, ein eigenes
Advisory hatten allein nodemailer, postcss und sharp. Die drei sind
Blattpakete und lassen sich gezielt ueberschreiben.

Nodemailer 9 kollidiert mit einer optionalen Peer-Angabe von next-auth
(hoechstens 8). Das ist folgenlos, weil der E-Mail-Provider von next-auth
hier nicht benutzt wird, sondern Nodemailer direkt.

Danach: 0 Schwachstellen.

Refs #38
"Der zweite Faktor ist Pflicht" stand im Kopf von auth.ts, durchgesetzt
wurde es aber nur im App-Layout. Ein Layout schuetzt, was durch das
Layout gerendert wird - Server Actions und Route Handler laufen daran
vorbei. Wer eine Passwort-Sitzung hatte, die TOTP-Einrichtung aber nie
abgeschlossen hat, kam an die Daten.

Die Pruefung steckt jetzt in erfordereBenutzer() selbst und damit ueber
erfordereZugang() automatisch in jeder Server Action. Massgeblich ist
der Datenbankstand, nicht das Feld im Sitzungstoken - das laesst sich
ueber session.update() vom Client beeinflussen.

Fuer Route Handler gibt es benutzerFuerRoute(): 401 bzw. 403 statt einer
Umleitung. Eine Anmeldeseite als Inhalt einer Word-Datei waere die
falsche Antwort.

Dabei aufgefallen: Der Dokumentenabruf unter /api/dokumente/datei fragte
nur nach der Anmeldung. Auch geschlossen.

Ausnahmen sind aufgezaehlt und werden von tests/mfa-grenze.test.ts
festgehalten - der Test liest den Quelltext und schlaegt an, sobald eine
weitere dazukommt.

Refs #38
Die Sperre hielt sich selbst am Leben. Jeder Aufruf wurde festgehalten,
auch der, der wegen der laufenden Sperre gar nicht geprueft wurde; die
Frist lief zugleich ab dem juengsten Fehlversuch. Wer also alle zehn
Minuten ein falsches Passwort an eine fremde Adresse schickte, sperrte
deren Inhaber dauerhaft aus - ohne das Passwort zu kennen. Jeder dieser
Aufrufe hinterliess ausserdem eine Zeile, die erst der Wartungslauf nach
30 Tagen wieder los wurde.

Jetzt zwei getrennte Dinge: Ein geprueft fehlgeschlagener Versuch zaehlt
und wird einzeln festgehalten. Ein waehrend der Sperre abgewiesener
Aufruf zaehlt nicht und wird hoechstens einmal je Sperre vermerkt.

Die Auswertung geht die Versuche von alt nach neu durch und zieht die
Runden nach: Ist das Mass voll, beginnt an diesem Versuch eine Sperre
und der Zaehler faengt von vorn an. Damit laeuft die Frist auch dann ab,
wenn die Fehlversuche noch im Zeitfenster stehen - vorher blieb der
Zugang bis zum Fensterende dicht.

Die Tests sind gegen vier Mutationen geprueft; jede wird rot.

Refs #38
Zwei Felder fuer zwei Luecken, in einer Migration:

benutzer.totpLetzterSchritt haelt das zuletzt akzeptierte TOTP-Fenster
fest. Ohne das gilt ein Code bis zu 90 Sekunden lang beliebig oft.

Die Tabelle ersteinrichtung hat genau eine Zeile mit fester Id. Das ist
kein Protokoll, sondern eine Sperre: Bisher entschied "gibt es schon
einen Benutzer?" und danach wurde angelegt - zwei Anfragen gleichzeitig
kamen beide durch. Jetzt entscheidet der Primaerschluessel.

Enthalten ist eine Datenmigration: Wo schon ein Zugang besteht, wird die
Sperrzeile gesetzt. Ohne sie stuende die Einrichtungsseite auf jedem
bestehenden System nach dem Update wieder offen.

Die DROP-INDEX-Zeilen zu den Trigramm-Indizes hat das Skript aus #36
wieder entfernt; "npm run db:pruefen" bestaetigt, dass beide stehen.

Refs #38
Vier Stellen, an denen eine Zusage nicht gehalten wurde.

1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit
   dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan
   hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90
   Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch
   zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von
   updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen
   nicht beide eine Zeile.

2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das
   ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage
   eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend
   Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern
   irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY
   gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine
   Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim
   ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt.

3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der
   dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen
   Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten.
   AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
   nicht stillschweigend uebergangen wird.

4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen.
   Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel
   ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks.
   Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste
   alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst
   verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft
   werden".

Refs #38
Die Aktion ist die einzige Stelle der Anwendung, an der jemand ohne
Anmeldung schreiben darf. Zwei Dinge fehlten.

Erstens stand die Seite zwischen dem ersten Start und dem Anlegen des
Zugangs fuer jeden offen: Wer sie in diesem Fenster fand, wurde
Administrator einer Anwendung mit Gesundheits- und Finanzdaten. Das
Fenster ist kurz, aber Zufall ist keine Zugriffskontrolle. Jetzt muss
ERSTEINRICHTUNG_GEHEIMNIS aus der .env mit eingegeben werden; verglichen
wird ueber Hashes in gleichbleibender Zeit, und ein falscher Wert
antwortet mit derselben Mindestdauer wie das Anmeldeformular.

Fehlt der Wert, ist die Einrichtung gesperrt statt offen. Das ist
Absicht: Eine Installation, bei der die Zeile vergessen wurde, soll eine
Meldung zeigen und nicht einrichtbar sein.

Zweitens waren Pruefung und Anlegen zwei Schritte. Beide laufen jetzt in
einer Transaktion, und die Sperrzeile mit fester Id entscheidet - eine
zweite gleichzeitige Anfrage scheitert am Primaerschluessel, bevor ein
zweiter Administrator entsteht.

Die Seite liest die Sperrzeile mit: Lag der erste Zugang im Papierkorb,
stand Schritt 1 sonst wieder offen. Der Seed setzt sie ebenfalls.

Refs #38
Zwei Wege, auf denen ein hochgeladenes Dokument den Server umbringen
konnte. Beide haben gemeinsam, dass die Groesse aus der Datei kam.

1. Dekompressionsbombe. Die PDF-Stroeme wurden mit inflateSync ohne
   maxOutputLength ausgepackt. Ein Flate-Strom sagt nicht, wie gross er
   ausgepackt wird - ein paar Kilobyte Fuellung blaehen sich auf Gigabyte
   auf. Jetzt ein Deckel je Strom (8 MB) und einer ueber die Summe
   (64 MB); zlib bricht selbst ab, der Strom wird verworfen, der Text ist
   eben unvollstaendig.

2. Riesenseiten. "pdftoppm -r 300" rendert mit fester Aufloesung, aber
   die Seitengroesse steht im PDF. Bei 200 x 200 Zoll ergeben 300 dpi ein
   Bild von 60000 x 60000 Pixeln, und zwar je Seite. Jetzt "-scale-to"
   statt fester dpi: Die laengste Kante bekommt eine feste Pixelzahl,
   unabhaengig davon, was im Dokument als Papierformat steht. Fuer A4
   entspricht das etwa den bisherigen 300 dpi. Dazu ein Deckel auf die
   Gesamtgroesse der gerenderten Dateien.

Der erste Bombentest war wertlos: Die Nutzlast enthielt keine
PDF-Textoperatoren, also war das Ergebnis mit und ohne Grenze leer. Mit
einer Marke in der Nutzlast und einer Gegenprobe knapp unter der Grenze
schlagen beide Mutationen an.

Refs #38
Im S3-Treiber stand nur ServerSideEncryption: 'AES256'. Das ist
Verschluesselung mit dem Schluessel des Anbieters - er entschluesselt beim
Ausliefern selbst. Gegen eine verlorene Platte im Rechenzentrum hilft das,
gegen den Anbieter und gegen gestohlene Zugangsdaten zum Eimer nicht. In
diesem Eimer liegen Bescheide, Arztbriefe und Kontoauszuege.

Der Dateitreiber hat es von Anfang an richtig gemacht. Der Umschlag zieht
deshalb nach src/lib/speicher/umschlag.ts um und wird von beiden benutzt,
statt ihn ein zweites Mal zu schreiben.

Neu ist die Bindung an den Ablageort: Der Schluessel des Objekts wandert
in die Zusatzdaten. Wer Schreibrechte auf den Eimer hat, kann die Bytes
von Dokument A damit nicht unter dem Namen von Dokument B ablegen - beim
Lesen schlaegt die Pruefung fehl, statt den falschen Bescheid mit
gueltiger Pruefsumme auszuliefern.

Damit fallen die presigned URLs weg. Sie liessen den Browser direkt beim
Anbieter laden; was dort liegt, ist jetzt Geheimtext. Die Auslieferung
geht ueber dieselbe eigene Route wie beim Dateitreiber - die prueft
zusaetzlich die Berechtigung und schreibt einen Audit-Eintrag. Ein
weitergeleiteter Link allein oeffnet also nichts mehr.

Bestehende Dateien bleiben durchgehend lesbar: Der Treiber erkennt am
Kopf, ob ein Umschlag da ist, und reicht alles andere unveraendert durch.
Auch die alten Umschlaege ohne Ortsbindung gehen weiter auf - sonst waere
die Verbesserung ein Datenverlust. Nachgezogen wird mit

    npm run speicher:verschluesseln            # nur schauen
    npm run speicher:verschluesseln -- --anwenden

Geprueft: 12 Tests gegen drei Mutationen, ein Trockenlauf ueber die 120
Dateien der Entwicklungsablage, und eine Probe mit einer Klartextdatei,
die nach dem Lauf verschluesselt auf der Platte liegt und sich trotzdem
korrekt zurueeklesen laesst.

Refs #38
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
florian.wilde merged commit 3525777fb4 into main 2026-08-24 10:35:20 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: florian.wilde/betreuungs-cockpit#39