Sicherheitsaudit - Bitte prüfen #38
Notifications
Total Time Spent: 2 hours 4 minutes
florian.wilde
2 hours 4 minutes
No due date set.
Dependencies
No dependencies set.
Reference: florian.wilde/betreuungs-cockpit#38
Reference in New Issue
Block a user
Security-Audit: Betreuungs-Cockpit (Stand
ece995b)Ziel
Die folgenden Punkte stammen aus einem statischen Security-/Code-Review sowie einem lokalen Build- und Testlauf des Commits
ece995b(v1.10.2). Bitte die Punkte in der angegebenen Reihenfolge beheben und für jede Sicherheitsgrenze einen Regressionstest ergänzen.Kurzfazit
Die Anwendung besitzt bereits viele gute Schutzmaßnahmen (serverseitige Mandantenzugriffsprüfung, Argon2id, AES-GCM für Felder und lokale Dateien, sichere Download-Header, CSP, Audit-Log). Vor einem produktiven Einsatz mit Gesundheits- und Finanzdaten sollten aber mindestens die Punkte P0 und P1 behoben werden.
[P0] Verwundbare Runtime-Dependencies unverzüglich aktualisieren
Fundstellen
package.json:next@15.1.6package.json:next-auth@5.0.0-beta.25/ transitiv@auth/core@0.37.2package.json:nodemailer@6.10.0package.json:@aws-sdk/client-s3@3.735.0und@aws-sdk/s3-request-presigner@3.735.0vitest@3.0.5,@playwright/test@1.50.1,tsx@4.19.2,postcss@8.5.1,eslint@9.19.0Evidenz
npm auditmeldet 32 Vulnerabilities: 5 critical, 5 high, 20 moderate, 2 low. Besonders relevant:next@15.5.23.next-auth@5.0.0-beta.32.9.0.5vor (Major-Upgrade).fast-xml-parsertransitiv über AWS SDK: kritische Entity-/Regex-Probleme; AWS SDK aktualisieren.Nicht jedes Advisory ist mit der aktuellen Konfiguration unmittelbar ausnutzbar (zum Beispiel wird kein NextAuth-E-Mail-Provider verwendet). Bei der Sensitivität der Daten ist die verwundbare Basis trotzdem nicht vertretbar.
Behebung
npm auditgenannte gepatchte Version aktualisieren; danach aktuellen Security-Stand erneut prüfen.package-lock.jsonneu erzeugen undnpm audit --omit=devsowie vollständigesnpm auditin CI als Gate ausführen.Akzeptanzkriterien
npm audit --omit=deventhält keine Critical/High-Funde.[P0] MFA-Schranke an jeder serverseitigen Fachoperation erzwingen
Fundstellen
src/auth.ts:144-147: Benutzer ohne eingerichtetes TOTP erhalten bewusst bereits eine gültige Sitzung.src/app/(app)/layout.tsx:22-23: MFA wird nur beim Rendern des App-Layouts erzwungen.src/lib/auth/sitzung.ts:50-116:erfordereBenutzer()underfordereZugang()prüfen nicht, ob MFA eingerichtet/abgeschlossen wurde.erfordereBenutzer()odererfordereZugang().src/server/actions/betreute.ts:111-157erlaubt mit einer Passwort-only-Sitzung das Anlegen einer Betreuung.src/app/(app)/schreiben/docx/route.ts,eml/route.tsund mehrere Downloads verlassen sich nur auf diese Helfer.Auswirkung
Ein neu angelegter Benutzer kann sich nur mit E-Mail und Passwort anmelden und erhält vor Abschluss der MFA-Einrichtung bereits ein gültiges JWT. Die UI leitet ihn zwar zur TOTP-Einrichtung um, Server Actions und Route Handler sind aber direkt aufrufbar. Damit ist „MFA Pflicht vor Fachdaten“ keine serverseitige Sicherheitsgrenze.
Bei einem neuen Admin ist das besonders kritisch: Mit der Passwort-only-Sitzung können direkt Fachaktionen angestoßen werden, ohne die TOTP-Einrichtung abzuschließen.
Behebung
erfordereZugang()muss diesen Guard standardmäßig einschließen.session.user.zweiterFaktorAktivvertrauen:jwt({ trigger: 'update' })übernimmt dieses Feld aktuell aus clientgesteuerten Session-Updates (src/auth.ts:331-343). Maßgeblich muss der frische DB-Zustand sein oder ein serverseitig ausgestelltes Auth-Level.Akzeptanzkriterien
session.update({ zweiterFaktorAktiv: true })schaltet keine Fachoperation frei.[P0] Erstinstallation gegen Übernahme und Race Conditions absichern
Fundstelle
src/server/actions/ersteinrichtung.ts:44-87Problem
erstenZugangAnlegen()ist unauthentifiziert und verwendet das Muster „user.count(); danachuser.create()“. Wird eine frisch gestartete Instanz vor dem Betreiber erreicht, kann jeder den ersten ADMIN anlegen. Außerdem können zwei parallele Requests beidecount() === 0sehen und anschließend zwei Administratoren mit unterschiedlichen E-Mail-Adressen anlegen. Es gibt keine DB-Constraint, die „genau ein erster Benutzer“ atomar erzwingt.Behebung
Eine der folgenden Varianten atomar umsetzen:
Zusätzlich die Initialisierung in einer serialisierbaren DB-Transaktion bzw. über eine eindeutige Setup-State-Zeile absichern. Ein bloßer zweiter
count()reicht nicht.Akzeptanzkriterien
[P1] Dokumente auch im S3-Treiber anwendungsseitig verschlüsseln
Fundstellen
src/lib/speicher/datei.ts:63-100: lokaler Dateitreiber verschlüsselt Inhalte korrekt mit AES-256-GCM.src/lib/speicher/s3.ts:45-61: S3-Treiber lädt den Klartext hoch und setzt nurServerSideEncryption: 'AES256'.docker-compose.yml: Produktion erzwingtSPEICHER_TREIBER=s3.Auswirkung
Bei SSE-S3 verwaltet der Storage-Anbieter den Schlüssel und sieht technisch den Klartext. Ein kompromittierter S3-Account, Anbieterzugriff oder falsch konfigurierter Bucket umgeht damit die in den Kommentaren formulierte Trennung „Daten und Anwendungsschlüssel werden zusammen benötigt“. Gerade Arztbriefe, Bescheide und Kontoauszüge sind besonders schützenswert.
Behebung
PutObjectund nachGetObjectanwenden.Akzeptanzkriterien
[P1] Unbegrenztes Schreiben in
login_versuchverhindernFundstellen
src/auth.ts:110-114: Auch bei bereits festgestellter Sperre wird erneutversuchFesthalten()aufgerufen.src/lib/auth/ratelimit.ts:56-73: Jeder Versuch erzeugt eine neue DB-Zeile.Auswirkung
Ein nicht authentifizierter Angreifer kann den Login-Endpunkt kontinuierlich aufrufen und pro Request dauerhaft eine Zeile erzeugen. Bereits gesperrte IP-/E-Mail-Kombinationen werden nicht früh verworfen, sondern schreiben weiter. Das ermöglicht DB-Wachstum, I/O-Last und letztlich Storage Exhaustion. Gleichzeitig verschiebt jeder neue Eintrag den jüngsten Fehlversuch und kann Sperren unbegrenzt verlängern.
Behebung
lastSeenmit begrenzter Kardinalität verwenden oder Schreibfrequenz drosseln.lockedUntilbinden, nicht mit jedem gesperrten Request verlängern, sofern eine permanente Verlängerung nicht ausdrücklich gewollt ist.Akzeptanzkriterien
[P1] Ressourcenlimits/Sandbox für PDF- und Bildverarbeitung ergänzen
Fundstellen
src/lib/ocr/pdf-text.ts:106-160: Angreifergesteuerte PDF-Streams werden synchron mitinflateSync,unzipSyncundinflateRawSyncohnemaxOutputLengthdekomprimiert.src/lib/ocr/index.ts:146-151:pdftoppmhat ein Zeitlimit, aber kein OS-/Container-Memory-/CPU-Limit.src/lib/ocr/index.ts:110-150: Tesseract/Poppler verarbeiten fremde Binärdaten direkt.Auswirkung
Ein berechtigter Benutzer (oder ein kompromittiertes Mitbearbeiterkonto) kann mit einer kleinen komprimierten Datei sehr hohe RAM-/CPU-Last erzeugen. Synchrone Dekompression blockiert zusätzlich den Node-Eventloop. Native Parser vergrößern die Angriffsfläche.
Behebung
zlibmitmaxOutputLengthund einem niedrigen globalen Dekompressionsbudget verwenden; synchrone Verarbeitung aus dem Request-Prozess entfernen.Akzeptanzkriterien
[P1] Passkey-Verwaltung nur nach frischer Reauth erlauben
Fundstellen
src/server/actions/passkey.ts:77-126: Passkey hinzufügen verlangt nurerfordereBenutzer().src/server/actions/passkey.ts:133-167: Passkey löschen verlangt ebenfalls nurerfordereBenutzer().erfordereFrischeAnmeldung().Auswirkung
Wer eine aktive Sitzung übernimmt (unbeaufsichtigtes Gerät, Cookie-Diebstahl), kann einen eigenen Passkey registrieren und erhält damit dauerhaften Zugang, selbst nachdem die Sitzung abläuft. Außerdem kann er legitime Passkeys entfernen.
Behebung
Akzeptanzkriterien
[P2] TOTP-Replay verhindern
Fundstellen
src/lib/crypto/totp.ts:69-84: Kommentar verspricht, dass der Aufrufer das getroffene Zeitfenster speichert.src/auth.ts:151-153undsrc/server/actions/bestaetigen.ts:59-63: Es wird nurgueltigausgewertet;delta/Zeitschritt wird nirgends gespeichert.Auswirkung
Derselbe abgefangene Code kann innerhalb des akzeptierten Fensters (aktuelle Periode plus Toleranz) mehrfach verwendet werden, beispielsweise einmal zur Anmeldung und erneut für eine sensible Reauth. TOTP ist damit faktisch nicht „one time“.
Behebung
Akzeptanzkriterien
[P2] Vertrauensgrenze für Proxy-/Client-IP-Header definieren
Fundstelle
src/auth.ts:65-68Problem
Die Anwendung verwendet ungeprüft den ersten Wert von
X-Forwarded-For, alternativX-Real-IP. Ist die App entgegen der Empfehlung direkt erreichbar oder überschreibt der Reverse Proxy eingehende Header nicht zuverlässig, kann der Client seine IP frei wählen und IP-basierte Limits umgehen.Behebung
trustHost: truenicht hart kodieren, sondern nur in kontrollierter Proxy-Konfiguration aktivieren.Akzeptanzkriterien
X-Forwarded-For-Werte seine Rate-Limit-Identität nicht ändern.[P3] Sicherheitslogik zentralisieren und irreführende Aussagen korrigieren
Beispiele
src/middleware.ts:25behauptet „Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen“, die Middleware prüft aber überhaupt kein Cookie.erfordereZugang(), Dokumentdownload, Kalenderroute, Export und App-Layout mehrfach manuell nachgebaut.src/lib/speicher/datei.ts:45behauptet, die kanonische Pfadprüfung fange Symlink-Tricks ab. Eine reineresolve()-Präfixprüfung verhindert keine bereits im Ablagebaum platzierten Symlinks.Behebung
requireAuthenticated,requireMfa,requireAccessundrequireRecentAuthschaffen und überall verwenden.lstat/realpath/O_NOFOLLOW-Strategie dokumentieren.Akzeptanzkriterien
Durchgeführte Prüfungen
ece995bnpm ci --ignore-scriptsmit lokalem Cache: erfolgreich (Peer-Warnungen zwischen NextAuth und SimpleWebAuthn)next build): erfolgreichtsc --noEmit): erfolgreichnpm run lint): erfolgreich, 0 Warnungen/Fehlervitest run): 28 Testdateien, 658 Tests, alle erfolgreichnpm audit: 32 Funde (5 critical, 5 high, 20 moderate, 2 low)Nicht vollständig dynamisch geprüft
.env, Browser-Binaries und einen separat gestarteten Dev-Server. Diese Infrastruktur wurde in diesem read-only Audit nicht aufgebaut; die vorhandenen E2E-Tests wurden daher nicht als bestanden gewertet.Empfohlene Reihenfolge
🤖 Claude Code hat mit der Umsetzung begonnen.
fix/38-sicherheitsauditVorab nachgeprüft. Ich habe die Befunde am Code gegengelesen, bevor ich angefangen habe. Ergebnis: P0-1, P0-2, P0-3, P1-1, P1-2, P1-3, P1-4, P2-1 und P2-2 sind zutreffend. Belege:
npm audit --omit=dev→ 27 Funde, davon 4 kritisch, 5 hoch in der Laufzeit (next,next-auth/@auth/core,fast-xml-parser,nodemailer,sharp).erfordereZweitenFaktor()wird an genau einer Stelle aufgerufen:src/app/(app)/layout.tsx:21. Kein Server Action, kein Route Handler prüft MFA.ersteinrichtung.ts:count()danncreate(), ohne Transaktion, ohne DB-Zwang.s3.tslädt Klartext hoch,datei.tsverschlüsselt mit AES-256-GCM — die Treiber sind ungleich.Eine Korrektur am Audit: Der Punkt P3 zur Middleware trifft nicht. Der Kommentar „Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen" steht in Zeile 33 (nicht 25) und gehört zur Konstante
OEFFENTLICHE_PFADE, die in Zeile 82 fürCache-Controlbenutzt wird. Der Kommentar ist irreführend platziert und wird korrigiert — die Middleware ist aber nicht als Zugriffsschutz gedacht und behauptet das an keiner anderen Stelle.Geplante Reihenfolge (abweichend vom Audit, mit Begründung):
count() > 0; und wem der Objektspeicher gehört, entscheidet über das Risiko).Zwischenstände kommen als Kommentare. Die Änderung an den Abhängigkeiten wird mit der vollen Testsuite geprüft, bevor irgendetwas anderes angefasst wird.
Zeiterfassung läuft.
⛔ Umsetzung fertig — aber der Push scheitert an der Anmeldung.
Alle Punkte sind umgesetzt, geprüft und lokal committet (10 Commits auf
fix/38-sicherheitsaudit, v1.11.0). Was fehlt, ist der Push:Der MCP-Zugang funktioniert weiterhin (
get_meantwortet), nur dasgespeicherte Git-Zugangsdatum im Windows-Credential-Manager wird
abgewiesen. Früher in dieser Sitzung ging der Push noch — vermutlich ist
das Token abgelaufen.
$env:GITEA_TOKENist in der Shell nicht gesetzt,ich kann also nicht ausweichen.
Was ich brauche: ein gültiges Git-Zugangsdatum. Danach lege ich PR an,
setze das Label auf
zu-testenund melde mich.Bis dahin steht die Uhr. Nichts geht verloren — die Arbeit liegt lokal.
Erledigt und lokal committet
erfordereBenutzer(). Dabei ein zweites Loch gefunden:/api/dokumente/dateifragte nur nach der Anmeldung.env+ Sperrzeile stattcount()-dann-create()X-Forwarded-Foraus dem falschen Ende gelesen und ohne Proxy geglaubt;trustHostfest auftrueEigener OWASP-Durchgang (Top 10, 2021): keine weiteren Funde.
Festgehalten in
docs/SICHERHEIT.md, samt Abschnitt über das, wasausdrücklich nicht geschützt ist. Zwei Verdachtsfälle haben sich
aufgelöst:
Prisma.join()fügt nur parametrisierte Fragmente zusammen,und der Suchausschnitt aus
ts_headlinewird als React-Kinder gerendert,nicht als HTML.
Geprüft:
tscsauber · Lint sauber · 709 Unit-Tests · 95 E2E(6 übersprungen, viewport-bedingt) ·
npm audit0 · Trockenlauf desMigrationsskripts über 120 Dateien · Rückwärtsverträglichkeit an einer
echten Altdatei.
Beim Update nötig — zwei neue
.env-Zeilen:Ohne die zweite zählt die Anmeldesperre nur noch nach E-Mail-Adresse.
Steht in
.env.example,INSTALLATION.mdund im Changelog.✅ Umsetzung abgeschlossen — bereit zum Testen.
Der Push ging beim zweiten Anlauf durch, der Blocker ist erledigt.
Was geändert wurde
Durchgang entlang der OWASP Top 10 ohne weitere Funde
richtiggestellt
docs/SICHERHEIT.mdneu: Bedrohungsmodell, OWASP-Durchgang, und einAbschnitt darüber, was ausdrücklich nicht geschützt ist
So testest du es
git switch fix/38-sicherheitsauditnpm install && npm run db:deploy && npm run devPasswort und Code
Vor dem Ausrollen zwei neue
.env-Zeilen, siehe PR-Text und Changelog.Aufgewendete Zeit: 1:57 (nachgetragen, weil die Uhr an einem anderen
Repo lief)
Pull Request: #39
Nach Freigabe:
/issue-freigabe 38🚀 Freigegeben und nach
mainübernommen.3525777Vor den Prüfungen noch einmal alles durchlaufen lassen: 709 Unit-Tests
grün, Arbeitsverzeichnis sauber, PR ohne Konflikte.
Beim Ausrollen nicht vergessen — zwei neue
.env-Zeilen:Und, nur mit Objektspeicher, einmalig:
Issue wird geschlossen.