Aufraeumrunde vor Etappe 2: Fehlerhygiene, Admin-Kriterium, Portal-Riegel
Drei Nachrangpunkte aus dem Pentest, vor dem Schneiden des Rechtekatalogs.
1. R192-01 projektweit. Das Muster `error instanceof Error ? error.message`
stand 124-mal in 26 Controllern und konnte ueberall Serverpfade,
Spaltennamen und Bibliotheksinterna ausliefern. Zentral geloest statt
124-mal einzeln - das waere die Falle aus R186-01 und R188 gewesen:
utils/fehlerAntwort.ts mit antworteAufFehler(), jetzt 123 Aufrufe in 26
Dateien und eine Regel.
Die Unterscheidung laeuft ueber die Fehlerklasse. Neue Basisklasse
FachlicherFehler fuer alles, dessen Wortlaut fuer den Aufrufer bestimmt
ist; ApiError, RechteEskalationError, RollenSperrError,
UngueltigeEingabeError, FilterFehler und ReferralError stammen davon ab.
Ein blankes Error gilt weiter als absichtlich. Alles andere - TypeError,
Prisma, JWT-Bibliothek - wird 500 mit allgemeiner Auskunft, Einzelheiten
ins Protokoll.
Mit gefunden: Sechs Stellen in cachedEmail.controller interpolierten die
interne Meldung in den Antworttext; das haette kein Filter erwischt, der
nur das Feld ersetzt. Und POST /auth/refresh gab den Wortlaut der
JWT-Bibliothek zurueck ("jwt malformed", "invalid signature") - der sagt
einem Angreifer, woran sein Token gescheitert ist.
2. Admin-Heuristik. Bisher galt "wer users:delete hat, ist Admin" - ein
Zufallsmerkmal. Bewusst NICHT auf den Rollennamen umgestellt, wie
vorgeschlagen: Eine selbst gebaute Rolle mit users:update verwaltet
tatsaechlich, ein Namenskriterium wuerde sie uebersehen, und dann liesse
sich der letzte Admin loeschen, obwohl die Faehigkeit erhalten bliebe.
Geschuetzt wird jetzt die Faehigkeit selbst: users:update und
roles:manage. Wer der letzte Traeger ist, kann sie nicht verlieren - durch
Rollenwechsel, Deaktivierung oder Loeschung. Die Meldung nennt die
Faehigkeit beim Namen. Nebenbei der letzte Cost-10-Rest im
Kennwort-Zuruecksetzen.
3. Portal-Kunden. Korrektur meiner eigenen Einordnung: Das war kein
Migrationsrueckstand, sondern eine gewollte Trennung. Kunden bekommen
niemals operative Rechte; die Portalansicht ist dafuer nicht gebaut und
prueft es nicht. Die zwei Kopien des festen Arrays sind jetzt eine
Konstante PORTAL_RECHTE mit einem Kommentar, der die Absicht benennt.
Dazu ein harter Riegel im Gate: requirePermission schneidet die Rechte
eines Portal-Zugangs auf PORTAL_RECHTE zu, unabhaengig davon, was sein
Token behauptet. Heute wirkungslos, morgen die Sicherung - bisher haette
eine unbedachte Zeile in der Token-Erzeugung gereicht. Und eine Startwache,
die jedes Nicht-Lese-Recht in dieser Liste meldet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,69 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
|
||||
|
||||
|
||||
- [x] **🧹 Aufräumrunde vor Etappe 2: drei Nachrangpunkte** (2026-09-09)
|
||||
|
||||
**1. R192-01 projektweit — interne Fehlermeldungen an den Client**
|
||||
- Das Muster `error instanceof Error ? error.message` stand **124-mal in 26
|
||||
Controllern**. Jede dieser Stellen konnte Serverpfade, Spaltennamen und
|
||||
Bibliotheksinterna ausliefern — kein Loch für sich, aber eine Landkarte.
|
||||
- Zentral gelöst statt 124-mal einzeln (die Falle aus R186-01/R188):
|
||||
`src/utils/fehlerAntwort.ts` mit `antworteAufFehler()`. Jetzt **123
|
||||
Aufrufe in 26 Dateien**, eine Regel.
|
||||
- Die Unterscheidung läuft über die **Fehlerklasse**: Neue Basisklasse
|
||||
`FachlicherFehler` (in `utils/apiError.ts`) für alles, dessen Wortlaut
|
||||
für den Aufrufer bestimmt ist — `ApiError`, `RechteEskalationError` (403),
|
||||
`RollenSperrError` (403), `UngueltigeEingabeError` (400), `FilterFehler`,
|
||||
`ReferralError` stammen jetzt davon ab. Ein blankes `Error` gilt weiter
|
||||
als absichtlich. Alles andere (TypeError, Prisma, JWT-Bibliothek) → **500**
|
||||
mit allgemeiner Auskunft, Einzelheiten ins Protokoll.
|
||||
- Mit gefunden: Sechs Stellen in `cachedEmail.controller.ts` **interpolierten**
|
||||
die interne Meldung in den Antworttext (`Fehler beim Speichern: ${msg}`) —
|
||||
die hätte kein Klassenfilter erwischt, der nur das Feld ersetzt.
|
||||
Und `POST /auth/refresh` gab den Wortlaut der JWT-Bibliothek zurück
|
||||
(„jwt malformed", „invalid signature") — der sagt einem Angreifer, **woran**
|
||||
sein Token gescheitert ist. Geht jetzt nur noch in den Alarmkanal.
|
||||
|
||||
**2. Admin-Heuristik — geschützt wird jetzt die Fähigkeit, nicht ihr Träger**
|
||||
- Bisher galt „wer `users:delete` hat, ist Admin". Ein Zufallsmerkmal: Wer
|
||||
Konten anlegen und bearbeiten darf, aber nicht löschen, verwaltet genauso —
|
||||
zählte aber nicht.
|
||||
- **Bewusst nicht auf die Rolle „Admin" umgestellt**, wie zunächst
|
||||
vorgeschlagen: Eine selbst gebaute Rolle mit `users:update` verwaltet
|
||||
tatsächlich, ein Namenskriterium würde sie übersehen — man könnte dann den
|
||||
letzten Admin löschen, obwohl die Verwaltungsfähigkeit erhalten bliebe.
|
||||
- Neu: `UNVERZICHTBARE_FAEHIGKEITEN` = `users:update` („Benutzer verwalten")
|
||||
und `roles:manage` („Rollen und Rechte pflegen"). Wer die letzte Person mit
|
||||
einer davon ist, kann sie nicht verlieren — durch Rollenwechsel,
|
||||
Deaktivierung oder Löschung. Die Meldung nennt die Fähigkeit beim Namen
|
||||
statt „letzter Administrator".
|
||||
- Nachgeprüft: Mit drei Trägern ist alles erlaubt; ist einer der letzte,
|
||||
→ 400 mit Klartext. Deaktivierung zählt als Verlust (ein inaktives Konto
|
||||
kann nichts, egal welche Rollen drankleben).
|
||||
- Nebenbei: `updateUser` hashte beim Passwort-Zurücksetzen noch mit Cost 10 —
|
||||
der letzte Rest der Inkonsistenz aus R191-01.
|
||||
|
||||
**3. Portal-Kunden — Trennung festgenagelt, nicht aufgelöst**
|
||||
- Korrektur meiner eigenen Einordnung: Ich hatte das als „noch zu migrieren"
|
||||
geführt. Falsch. Kunden bekommen **niemals** operative Rechte; die
|
||||
Portalansicht ist dafür nicht gebaut und prüft es nicht.
|
||||
- Die zwei Kopien des festen Arrays in `auth.service.ts` sind jetzt eine
|
||||
Konstante `PORTAL_RECHTE` mit einem Kommentar, der die Trennung als
|
||||
Absicht benennt.
|
||||
- **Harter Riegel im Gate:** `requirePermission` schneidet die Rechte eines
|
||||
Portal-Zugangs auf `PORTAL_RECHTE` zu — unabhängig davon, was sein Token
|
||||
behauptet. Heute wirkungslos (sie halten ohnehin nur diese zwei), morgen
|
||||
die Sicherung: Bisher hätte eine unbedachte Zeile in der Token-Erzeugung
|
||||
gereicht, um die Trennung zu kippen.
|
||||
- **Startwache:** `pruefePortalRechte()` meldet beim Start jedes Recht in
|
||||
`PORTAL_RECHTE`, das nicht Lesen ist. Damit überlebt die Regel den Tag, an
|
||||
dem jemand „nur kurz" etwas hinzufügt.
|
||||
- Dateien: `utils/fehlerAntwort.ts` (neu), `utils/apiError.ts`,
|
||||
`config/rechte-katalog.ts`, `middleware/auth.ts`, `services/auth.service.ts`,
|
||||
`services/user.service.ts`, `services/rechte.service.ts`,
|
||||
`services/pflichtrechte.service.ts`, 26 Controller
|
||||
|
||||
- [x] **🚨 R193-02: Truthiness-Bypass bei den drei Haken (HIGH)** (2026-09-09)
|
||||
- Befund der Pentesterin, **Prod-Blocker**: Der Eskalations-Guard fragte
|
||||
`=== true`, die Zuweisung fragte auf Truthiness. Zwischen diesen beiden
|
||||
|
||||
Reference in New Issue
Block a user