Plan, Zusammenfassung, Verifikation und STATE.md zum Quick-Vorgang 260921-fi3. Befund: auth.service legt mustChangePassword in den JWT, jwt.strategy liess das Feld beim Auspacken fallen. request.user.mustChangePassword war damit immer undefined und der global registrierte ForcePasswordChangeInterceptor hat seit seiner Einfuehrung nie blockiert. Durchgesetzt wurde der Zwangswechsel allein von der Web-Middleware; jeder Weg daran vorbei umging ihn. Gemessen: eine Sitzung mit mustChangePassword=true erhielt auf GET /users 200 samt vollstaendiger Benutzerliste. Behoben, und belegt bei gleicher Rolle und gleicher Route: GET /modules/active liefert 403 FORCE_PASSWORD_CHANGE mit Zwang und 200 ohne. Neue Spezifikationen gegen den alten Stand 6 von 12 rot, danach 12 von 12 gruen, vom Verifier unabhaengig nachgestellt. Der Browser-Ablauf wurde vollstaendig durchgespielt: niemand wird ausgesperrt, der Wechsel gelingt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
35 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-260921-fi3 | 01 | execute | 1 |
|
true |
|
|
|
Befund 1 (hoch, sicherheitsrelevant). AuthService.login legt mustChangePassword in das Sitzungs-Token (auth.service.ts:176), JwtStrategy.validate gibt aber nur id, username, role und tenantId zurueck und laesst das Feld fallen. Der global registrierte ForcePasswordChangeInterceptor (app.module.ts:73) prueft request.user.mustChangePassword — ein Wert, der damit immer undefiniert ist. Der Abfanger hat also noch nie etwas blockiert; seine eigene Kommentarzeile behauptet das Gegenteil. Durchgesetzt wird der Zwangswechsel heute einzig in der Next.js-Zwischenschicht (apps/web/src/middleware.ts:146), die das Token selbst liest. Alles, was ohne diese Zwischenschicht an der API haengt — der Tauri-Desktop-Klient, ein Skript, curl — geht am Zwangswechsel vorbei. Der Orchestrator hat das am laufenden System reproduziert: frisch angemeldet mit gesetzter Kennzeichnung lieferte GET /users 200 mit vollstaendiger Benutzerliste. Behoben wird die Ursache, nicht das Symptom (D-01), und zusaetzlich die zweite, heute noch nicht ausnutzbare Schwaeche derselben Datei: die Erlaubnisliste vergleicht per Teilstring und ohne Ruecksicht auf die HTTP-Methode.
Befund 2 (mittel). In VehicleTable.tsx wird das Beschaeftigt-Kennzeichen geschrieben, aber nie gelesen (const [, setIsDeleting]). Dialog und Bestaetigungsschaltflaeche bleiben waehrend der laufenden Loeschanfrage bedienbar, ein zweiter Klick schickt ein zweites DELETE auf denselben Datensatz. Dieselbe Datei traegt sieben fest verdrahtete deutsche Zeichenketten und vier fest verdrahtete Vorlesehilfen, die ueber next-intl gehoeren (D-04).
Befund 3 (niedrig) — bewusst keine Aenderung, begruendet. downloadAllAsZip in SplitTab.tsx legt den Dateinamen certificates.zip fest. Die Funktion steht auf Modulebene und kann den Uebersetzungshaken nicht aufrufen; ein uebersetzter Name muesste als Parameter von aussen hereingereicht werden. Das ist den Umbau nicht wert: ein Downloadname ist kein Bedienelement, sondern ein Dateisystem-Artefakt. Er wird nie vorgelesen, nie gesucht, nie angeklickt; er erscheint einmal im Download-Ordner. Uebersetzte Dateinamen bringen dafuer echte Nachteile — Umlaute in Dateinamen auf Windows-Freigaben, Anhaenge, die je nach Sprache des Absenders anders heissen, und ein Bauteil, das eine Zeichenkette nur noch durchreicht. Der ASCII-Name bleibt so, wie er ist; die Dateien IM Archiv tragen ohnehin die vom Server gelieferten Zertifikatsnamen. Die Datei wird in dieser Aufgabe nicht angefasst.
Purpose: Eine Zugangssperre, die seit ihrer Einfuehrung tot war, wirklich scharf stellen — mit Tests, die beweisen, dass sie lebt (D-03) — und zwei Bedienmaengel in der Fahrzeugtabelle beseitigen, ohne bestehende Berechtigungen zu schwaechen (D-02). Output: Zwei geaenderte API-Dateien plus zwei neue Testdateien, ein geaendertes Web-Bauteil plus erweiterte Testdatei, sieben neue Uebersetzungsschluessel in beiden Sprachen, und ein am HTTP gemessener Nachweis.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
@apps/api/src/auth/strategies/jwt.strategy.ts @apps/api/src/auth/interceptors/force-password-change.interceptor.ts @apps/api/src/tenant/tenant.guard.spec.ts @apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx @apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx
Beim Planen gelesen und gemessen — nicht noch einmal oeffnen, die Ergebnisse stehen hier:
apps/api/src/app.module.ts:73— der Abfanger ist global alsAPP_INTERCEPTORregistriert. Waechter laufen in NestJS vor Abfangern,request.userist zum Zeitpunkt des Abfangens also bereits gefuellt.apps/api/src/main.ts— keinsetGlobalPrefix.request.route.pathist damit der volle Pfad, z. B./auth/me. Die API lauscht auf Port 3001.apps/api/src/auth/auth.service.ts:176—loginlegt den Anspruch ins Token.auth.service.ts:373-389—changePasswordsetzt in der DatenbankmustChangePassword:falseund signiert ein neues Token mitmustChangePassword:falseund setzt es als Cookie.apps/web/src/lib/auth-actions.ts:140-159— die Serveraktion liest denset-cookie-Kopf aus der API-Antwort, setzt ihn im Browser und leitet auf/um. Die Kennzeichnung bleibt nach erfolgreichem Wechsel also nicht haengen, undapps/api/src/auth/auth.service.spec.ts:610pinnt genau das bereits als Test. Hier ist nichts zu tun.- Welche Wege der Browser im Zwangswechsel wirklich geht (D-02, vollstaendig nachgelesen):
/change-passwordliegt in der Routengruppe(portal), also innerhalb derAppShell. Auf dieser Seite laufen genau vier API-Aufrufe: der Kopfbereich ruftfetchSessionState()→GET /auth/me(erlaubt), die Seitenleiste ruftGET /modules/active(wird kuenftig 403 — sie faengt jeden nicht-ok-Fall wortlos ab,sidebar.tsx:45-55, die Seite bricht nicht), das Profilbild im Kopfbereich ruftGET /users/me/avatar(wird kuenftig 403 statt wie heute 404 — der Kopfbereich hat einenonError-Rueckfall auf die Initiale), und das Formular schicktPOST /auth/change-password(erlaubt). Der Wurzel-Layout und die Anbieter-Bauteile machen keine API-Aufrufe. Die drei Eintraege der Erlaubnisliste decken den Ablauf im Browser damit vollstaendig ab. grep -rn "FORCE_PASSWORD_CHANGE" apps— kein Abnehmer im Frontend. Der Antwortkoerper des 403 ist frei, wird aber trotzdem unveraendert gelassen.- Testmuster fuer Waechter und Abfanger:
apps/api/src/tenant/tenant.guard.spec.tsundapps/api/src/module-registry/module.guard.spec.ts— HilfsfunktionmakeContext(request), direkte Konstruktion ohne Nest-Testmodul, Testnamen auf Deutsch. Es gibt kein supertest und keinTest.createTestingModulein apps/api — nicht damit anfangen. apps/web/src/messages/umlaut-guard.spec.ts— prueft de.json/en.json auf neu eingefuehrte Ersatzschreibungen (ae/oe/ue/ss). Neue deutsche Texte brauchen echte Umlaute. Die in Aufgabe 2 vorgegebenen Zeichenketten sind daraufhin geprueft und unbedenklich.apps/web/src/messages/de.json, BereichdkvFleet— vorhandene Unterbereichecol,form,errors,status,tabs. Vorhandene Schluessel u. a.deleteVehicle= "Fahrzeug löschen",editVehicle= "Fahrzeug bearbeiten",form.cancel= "Bearbeitung abbrechen",form.cancelDialog= "Abbrechen",form.save= "Einstellungen speichern". Bestandstexte dieses Bereichs duzen teilweise; sie werden nicht angefasst (D-05, kein repo-weites Umschreiben). Die neuen Texte sind unpersoenlich formuliert und erfuellen D-04 damit ohne Anrede-Konflikt.- Gemessener Ausgangsstand:
pnpm lint→Tasks: 5 successful, 5 total,@tessera/api: Found 357 warnings,@tessera/web: Found 107 warnings, Summe 464. Datenbank: admin, nutzer1, nutzer2 allemustChangePassword = f./healthantwortet ohne Anmeldung mit 200.
jwt.strategy.spec.ts (neu). Muster von apps/api/src/tenant/tenant.guard.spec.ts uebernehmen: direkte Konstruktion, Testnamen auf Deutsch, kein Nest-Testmodul. new JwtStrategy({ get: () => 'test-secret' } as any). Drei Faelle aus dem Verhaltensblock.
force-password-change.interceptor.spec.ts (neu). Hilfsfunktion makeContext(request) wie im Vorbild, zusaetzlich getHandler() und getClass() als leere Funktionen, weil der Abfanger den Reflektor darauf anwendet. Reflektor als { getAllAndOverride: () => false } as any, fuer den Fall der oeffentlichen Route () => true. Aufrufkette als { handle: () => of('ok') } as any mit of aus rxjs. Der Nahttest baut sein request.user nicht von Hand, sondern aus await new JwtStrategy(...).validate({ sub: 'u1', username: 'u', role: 'USER', tenantId: 't1', mustChangePassword: true }) — nur so pinnt er die Naht und scheitert gegen den heutigen Quelltext.
jwt.strategy.ts. In validate das Rueckgabeobjekt um mustChangePassword ergaenzen, normalisiert auf einen echten Wahrheitswert per strengem Vergleich des Anspruchs mit true (D-01). Begruendung als kurzer Kommentar: ein vor dieser Aenderung ausgestelltes Token traegt den Anspruch nicht und ergibt damit false — laufende Sitzungen verhalten sich exakt wie bisher, es gibt keine Aussperrwelle.
force-password-change.interceptor.ts. Die bisherige Teilstring-Pruefung auf dem Pfad ersetzen (D-01, zweite Schwaeche):
- Auf Modulebene eine unveraenderliche Liste von Objekten mit den Feldern
methodundpathanlegen, genau drei Eintraege:POST+/auth/change-password,POST+/auth/logout,GET+/auth/me. Kein Eintrag mehr und kein Eintrag weniger — das ist genau der Satz, den der Anmeldeablauf im Browser braucht (D-02, im Kontextblock nachgemessen). - Pfad normalisieren: bevorzugt
request.route?.path, ersatzweiserequest.url; Abfrageteil ab dem Fragezeichen abschneiden, abschliessende Schraegstriche entfernen, leeres Ergebnis auf/zuruecksetzen. - Methode ueber
request.methodin Grossbuchstaben. - Durchgelassen wird nur, wenn ein Listeneintrag in beiden Feldern exakt gleich ist. Bewusste Entscheidung:
HEADwird nicht zusaetzlich zugelassen, weil kein Aufrufer im BestandHEADbenutzt und die kleinere Flaeche die sichere Richtung ist. - Die Pruefung auf die gesetzte Kennzeichnung auf einen strengen Vergleich mit
trueziehen. @Public-Abkuerzung, Abkuerzung bei fehlendemrequest.userund der geworfeneForbiddenExceptionsamt Antwortkoerper bleiben Zeichen fuer Zeichen unveraendert.- Den Kopfkommentar der Datei auf den wahren Stand bringen: die Behauptung zu T-02-14 stimmt erst ab jetzt, und sie stimmt nur, weil
JwtStrategy.validatedas Feld durchreicht — diesen Zusammenhang benennen, damit niemand das Feld dort wieder herauskuerzt. Die alte Pruefmethode darf im Kommentar nicht als Code-Ausdruck zitiert werden (ein Abnahmetor zaehlt sie auf null). pnpm --filter @tessera/api exec vitest run src/auth/strategies/jwt.strategy.spec.ts src/auth/interceptors/force-password-change.interceptor.spec.ts test "$(grep -c mustChangePassword apps/api/src/auth/strategies/jwt.strategy.ts)" -ge 1 && echo "OK: Anspruch wird durchgereicht" || { echo "FAIL: Feld fehlt in validate"; false; } I=apps/api/src/auth/interceptors/force-password-change.interceptor.ts; test "$(grep -vE '^:space:*(*|//)' "$I" | grep -cF 'path.includes')" = 0 && echo "OK: keine Teilstring-Pruefung mehr" || { echo "FAIL: alte Pruefform noch vorhanden"; false; } # heute gemessen: 1 docker compose up -d --build api >/dev/null 2>&1; curl -s --retry 40 --retry-delay 2 --retry-connrefused -o /dev/null http://localhost:3001/health; docker compose exec -T db psql -U tessera -d tessera -q -c "update "User" set "mustChangePassword"=true where username='admin';"; C=$(mktemp); curl -s -o /dev/null -c "$C" -X POST http://localhost:3001/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}'; echo "GET /users -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" http://localhost:3001/users)"; echo "GET /modules/active -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" http://localhost:3001/modules/active)"; echo "GET /auth/me -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" http://localhost:3001/auth/me)"; echo "POST /auth/change-password -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" -X POST http://localhost:3001/auth/change-password -H 'Content-Type: application/json' -d '{"currentPassword":"falsch","newPassword":"egal12345"}')"; echo "POST /auth/logout -> $(curl -s -o /dev/null -w '%{http_code}' -b "$C" -X POST http://localhost:3001/auth/logout)"; docker compose exec -T db psql -U tessera -d tessera -q -c "update "User" set "mustChangePassword"=false where username='admin';" # erwartet: 403, 403, 200, 401, 200 — und danach steht admin wieder auf false Die beiden neuen Testdateien sind gruen und der Nahttest scheitert nachweislich gegen den alten Quelltext (vor dem Anpassen vonjwt.strategy.tseinmal laufen lassen und den roten Lauf im Bericht festhalten, D-03). Am laufenden System liefert eine Sitzung mit gesetzter Kennzeichnung:GET /users403,GET /modules/active403,GET /auth/me200,POST /auth/change-password401 (Fachfehler des Handlers, also durchgelassen — ein 403 waere ein Fehlschlag, D-02),POST /auth/logout200. Die Kennzeichnung von admin steht danach wieder auf false.
VehicleTable.tsx — Doppelausloesung. Das Beschaeftigt-Kennzeichen wieder lesbar machen: die Zerlegung des Zustands so schreiben, dass auch der Lesewert gebunden ist. Dann:
confirmDeleteam Anfang zusaetzlich abbrechen, wenn bereits geloescht wird (Wiedereintritts-Sperre im Zustandsobjekt selbst, nicht nur in der Darstellung).DeleteDialogbekommt eine zusaetzliche Eigenschaft fuer den Beschaeftigt-Zustand; Bestaetigen und Abbrechen werden damit gesperrt, und die Bestaetigungsschaltflaeche erhaelt dieselben Sperr-Klassen, die die Bauteile dieses Moduls schon benutzen (disabled:opacity-50 disabled:cursor-not-allowed, Vorbild im Werkzeugbalken derselben Datei).- Der Aufrufer reicht den Zustand hinein.
VehicleTable.tsx — Texte. Alle fest verdrahteten Zeichenketten durch Aufrufe des Uebersetzers ersetzen: die Aufschrift der Bestaetigungsschaltflaeche im Dialog (form.deleteConfirm), die Meldung im Fehlerzweig von load (errors.loadVehiclesFailed), die Pflichtfeldmeldung an beiden Stellen (errors.licensePlateRequired), den Fehlerzweig beim Bearbeiten-Speichern (errors.saveVehicleFailed), beim Anlegen (errors.createVehicleFailed) und beim Loeschen (errors.deleteVehicleFailed). Ebenso alle sechs Fundstellen der Vorlesehilfen in den Zeilenschaltflaechen, die heute als Zeichenkettenliteral am Attribut haengen (vier verschiedene Beschriftungen; Speichern und Abbrechen kommen je zweimal vor, einmal in der Bearbeitungszeile und einmal in der neuen Zeile) — sie sind fuer Bildschirmleser sichtbarer Text und gehoeren damit unter D-04: Bearbeiten auf editVehicle, Loeschen auf deleteVehicle, Abbrechen auf form.cancel (alle drei existieren bereits und liefern woertlich dieselben Texte wie heute) und Speichern auf den neuen form.saveRow. Das Attribut darf danach in der Datei kein Zeichenkettenliteral mehr tragen, nur noch geschweifte Klammern — ein Abnahmetor zaehlt die Literalform auf null (heute gemessen: 6). Der Platzhalter des Kennzeichenfelds (M-AB 123) bleibt: er ist ein Formatbeispiel, kein Satz, und in beiden Sprachen gleich. Keine der entfernten Zeichenketten darf als Kommentar in der Datei stehen bleiben.
VehicleTable.test.tsx. Die Abbildung im next-intl-Nachbau um 'form.saveRow': 'Änderungen speichern' ergaenzen, damit die zwei bestehenden Testfaelle, die ueber diesen Namen suchen, unveraendert gruen bleiben. form.deleteConfirm und die fuenf Fehlerschluessel nicht in die Abbildung aufnehmen — der Nachbau gibt unbekannte Schluessel unveraendert zurueck, und genau das macht die Textherkunft pruefbar. Den bestehenden Testfall zum Loeschdialog auf den Schluesselnamen umstellen. Drei Testfaelle neu ergaenzen, wie im Verhaltensblock beschrieben: Doppelklick fuehrt zu genau einem DELETE, Dialogschaltflaechen sind waehrend des Laufs gesperrt, Ladefehler zeigt den Schluessel der Ladefehlermeldung. Fuer den Doppelklick eine Zusage benutzen, deren Aufloesung der Test selbst in der Hand hat, damit der Zwischenzustand ueberhaupt beobachtbar ist.
pnpm --filter @tessera/web exec vitest run "src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx"
pnpm --filter @tessera/web exec vitest run src/messages/umlaut-guard.spec.ts src/messages/tenderRadar-parity.spec.ts
F="apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx"; test "$(grep -cE "(setTableError|setEditError|setNewRowError)('" "$F")" = 0 && echo "OK: kein Fehlertext mehr als Literal" || { echo "FAIL"; false; } # heute gemessen: 6; die Form mit null als Argument loest bewusst nicht aus (heute 3, geprueft)
F="apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx"; test "$(grep -cF 'aria-label="' "$F")" = 0 && echo "OK: alle Vorlesehilfen aus next-intl" || { echo "FAIL"; false; } # heute gemessen: 6
node -e "const f=(o,p='')=>o&&typeof o==='object'?Object.entries(o).flatMap(([k,v])=>f(v,p?p+'.'+k:k)):[p];const de=f(require('./apps/web/src/messages/de.json').dkvFleet).sort(),en=f(require('./apps/web/src/messages/en.json').dkvFleet).sort();const miss=[...de.filter(k=>!en.includes(k)).map(k=>'nur de: '+k),...en.filter(k=>!de.includes(k)).map(k=>'nur en: '+k)];console.log(miss.length?miss.join('\n'):'dkvFleet-Schluessel deckungsgleich: '+de.length);process.exit(miss.length?1:0)"
Die Testdatei laeuft mit mindestens acht Testfaellen gruen (fuenf alte, drei neue); der Doppelklick-Fall zaehlt genau einen Aufruf von deleteVehicle.
Waechter- und Gleichheitstest der Sprachdateien sind gruen, beide Sprachen tragen im Bereich dkvFleet denselben Schluesselsatz (D-04).
Die beiden Strukturtore zaehlen auf null: kein Fehlertext mehr als Literal an einem Fehlersetzer, kein Vorlesehilfen-Attribut mehr mit Zeichenkettenliteral.
SplitTab.tsx ist unveraendert (Befund 3, bewusste Entscheidung aus dem Objective).
Reihenfolge: pnpm type-check (erwartet 4 von 4), pnpm test (erwartet apps/api 69 Dateien mit mindestens 1124 Tests, apps/web 66 Dateien mit mindestens 459 Tests — die Zahlen duerfen durch die neuen Testfaelle steigen, die Dateizahl von apps/api steigt um die zwei neuen Dateien auf 71), pnpm lint (erwartet 5 successful, 5 total und damit null Befunde der Stufe Fehler).
Die Warnungssumme aus dem Lint-Lauf ziehen und gegen die Obergrenze halten: Ausgangsstand 464 (api 357, web 107), erlaubt sind hoechstens 466 (D-06). Steigt sie staerker, die neuen Zeilen nacharbeiten statt die Grenze anzuheben.
Anschliessend den Ausgangsstand der Datenbank pruefen und, falls noetig, wiederherstellen: admin, nutzer1 und nutzer2 alle mit mustChangePassword = f. Der Testlauf aus Aufgabe 1 setzt die Kennzeichnung zwischendurch; ein Abbruch mittendrin kann sie stehen lassen.
Im Abschlussbericht festhalten: die fuenf Zahlen vorher/nachher, das Ergebnis der HTTP-Messung aus Aufgabe 1 als Tabelle, und die Begruendung zu Befund 3 in einem Satz — damit der naechste Durchgang die Datei nicht erneut als offenen Punkt aufgreift.
Kein repo-weites Formatieren, keine Versionsspruenge, keine neuen Abhaengigkeiten (D-05).
pnpm type-check 2>&1 | tail -3
pnpm test 2>&1 | grep -E "Test Files|Tests |Tasks:" | tail -8
pnpm lint 2>&1 | grep -E "Found [0-9]+ warnings|Tasks:" ; pnpm lint 2>&1 | grep -oE 'Found [0-9]+ warnings' | grep -oE '[0-9]+' | paste -sd+ | bc # erwartet: <= 466
docker compose exec -T db psql -U tessera -d tessera -tAc "select count(*) from "User" where "mustChangePassword"=true;" # erwartet: 0
pnpm type-check 4/4, pnpm lint 5/5 ohne Fehlerstufe, Warnungssumme hoechstens 466.
pnpm test gruen in beiden Arbeitsbereichen, keine vorher gruene Datei jetzt rot.
Kein Benutzer traegt mehr die Zwangswechsel-Kennzeichnung — Datenbank wie vorgefunden.
Der Abschlussbericht nennt die Zahlen, die HTTP-Tabelle und die Begruendung zu Befund 3.
<threat_model>
Vertrauensgrenzen
| Grenze | Beschreibung |
|---|---|
| Browser -> Next.js-Zwischenschicht | Liest das Sitzungs-Token selbst und leitet bei gesetzter Kennzeichnung auf /change-password um. Wirkt nur fuer Seitenaufrufe im Browser. |
| Beliebiger Klient -> NestJS-API (Port 3001) | Die eigentliche Grenze. Sitzungs-Cookie mit HS256-Token. Tauri-Desktop-Klient, Skripte und curl kreuzen sie ohne die Zwischenschicht. |
| Browser -> Fahrzeugtabelle -> DELETE /dkv/vehicles/:id | Bedienoberflaeche als einzige Sperre gegen mehrfaches Absenden derselben Absicht. |
STRIDE-Register
| Kennung | Kategorie | Bauteil | Schwere | Umgang | Massnahme |
|---|---|---|---|---|---|
| T-FI3-01 | Elevation of Privilege / fehlende Zugriffskontrolle | JwtStrategy.validate + ForcePasswordChangeInterceptor |
high | mitigate | validate reicht mustChangePassword an request.user durch, der Abfanger vergleicht streng auf true und sperrt alles ausserhalb der Erlaubnisliste. Nachweis am HTTP: 403 auf /users und /modules/active. Gepinnt durch den Nahttest, der gegen den alten Quelltext scheitert (Aufgabe 1, D-03). |
| T-FI3-02 | Spoofing / Umgehung der Erlaubnisliste | ForcePasswordChangeInterceptor |
medium | mitigate | Teilstring-Vergleich ohne Methodenpruefung wird durch exakten Vergleich von Methode und normalisiertem Pfad ersetzt. Heute nicht ausnutzbar (keine kollidierende Route, nachgeprueft), deshalb im Test statt am HTTP nachgewiesen. |
| T-FI3-03 | Information Disclosure / veralteter Anspruch | Sitzungs-Token (30 Tage Laufzeit) | low | accept | Die Kennzeichnung ist eine Momentaufnahme der Anmeldung. Setzt ein Admin sie einem bereits angemeldeten Nutzer, greift sie erst bei dessen naechster Anmeldung. Das galt fuer die Zwischenschicht genauso und aendert sich durch diese Aufgabe nicht. Bewusst angenommen: der Fall entsteht praktisch nur zusammen mit adminResetPassword, und dabei wird ohnehin das Kennwort gewechselt, was die alte Sitzung fuer den Nutzer wertlos macht. Eine Gegenmassnahme waere ein Abgleich gegen die Datenbank bei jeder Anfrage — eine Datenbankabfrage pro Aufruf, hier nicht verhaeltnismaessig. |
| T-FI3-04 | Tampering / doppelte Schreibabsicht | VehicleTable.confirmDelete |
low | mitigate | Wiedereintritts-Sperre im Zustand plus gesperrte Dialogschaltflaechen; im Komponententest auf genau einen Aufruf gepinnt. Serverseitig war der zweite Aufruf schon bisher harmlos (der Datensatz ist dann fort), der Schaden lag in der Bedienung. |
Keine Paketinstallation in dieser Aufgabe (D-05), damit entfaellt das Paketechtheits-Tor.
Was war offen, und wie weit reichte es
Solange der Abfanger tot war, galt: wer ein Kennwort gesetzt bekommt, das ein anderer kennt — genau das tut adminResetPassword, Vorgabe mustChangePassword = true —, konnte mit diesem Kennwort die vollstaendige API benutzen, ohne je ein eigenes Kennwort zu setzen. Reichweite: die eigene Rolle und der eigene Mandant des betroffenen Nutzers. Keine Rechteausweitung — Rollen- und Mandantenwaechter waren und sind unberuehrt, ein Nutzer wurde dadurch nicht zum Admin. Der Schaden ist ein anderer: ein Zugang, den zwei Personen kennen, blieb unbefristet voll nutzbar. Im Browser fiel das nicht auf, weil die Zwischenschicht umleitete; ueber den Desktop-Klienten, ein Skript oder curl fiel die Sperre ersatzlos aus.
Was die gehaertete Erlaubnisliste weiterhin nicht abdeckt
- Oeffentliche Routen bleiben aussen vor.
@Publicwird vor der Kennzeichnung geprueft:/auth/login,/auth/request-reset,/auth/reset-password,/health,/health/versionund die drei Desktop-Aktualisierungswege bleiben auch im Zwangswechsel erreichbar. Fuer die Selbstbedienungs-Ruecksetzung ist das gewollt — sie ist ein zweiter, gleichwertiger Weg aus dem Zwangswechsel heraus. - Laufende Sitzungen, siehe T-FI3-03.
- Nur HTTP. Der Abfanger greift ueber
switchToHttp(). Kaeme spaeter ein anderer Transport dazu, waere er dort ohne Wirkung — dann muss diese Datei erneut angefasst werden. - Keine Ratenbegrenzung. Der Abfanger verhindert die Nutzung, nicht das wiederholte Anklopfen. </threat_model>
<success_criteria>
- Der erzwungene Passwortwechsel wird an der API durchgesetzt, nicht nur im Browser; die Ursache — das unterwegs verlorene Feld — ist behoben, nicht das Symptom (D-01).
- Kein bestehender Zugriff wurde eingeschraenkt: eine normale Sitzung verhaelt sich unveraendert, und ein Nutzer im Zwangswechsel kann den Wechsel im Browser vollstaendig durchfuehren (D-02).
- Es gibt Tests, die gegen den alten Quelltext scheitern, und einen, der die Teilstring-Falle schliesst (D-03).
- Der Loeschknopf der Fahrzeugtabelle laesst sich nicht mehr doppelt ausloesen, bewiesen im Komponententest.
- Alle sichtbaren Texte und Vorlesehilfen der Fahrzeugtabelle stammen aus beiden Sprachdateien, deutsche Texte unpersoenlich und mit echten Umlauten (D-04).
SplitTab.tsxist unangetastet; die Begruendung steht im Abschlussbericht (Befund 3).- Keine neuen Abhaengigkeiten, keine Versionsspruenge, kein repo-weites Formatieren (D-05); alle gruenen Balken stehen, Warnungssumme nicht materiell gewachsen (D-06).
- Die Datenbank steht am Ende exakt so da wie vorgefunden. </success_criteria>