diff --git a/.planning/quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/260921-i8x-PLAN.md b/.planning/quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/260921-i8x-PLAN.md
new file mode 100644
index 0000000..24cb523
--- /dev/null
+++ b/.planning/quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/260921-i8x-PLAN.md
@@ -0,0 +1,530 @@
+---
+phase: quick-260921-i8x
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+autonomous: true
+requirements: [D-01, D-02, D-03, D-04, D-05, D-06]
+files_modified:
+ - apps/web/src/lib/safe-next.ts
+ - apps/web/src/lib/safe-next.test.ts
+ - apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx
+ - apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx
+ - apps/web/src/app/(portal)/modules/cert-manager/actions.ts
+ - apps/api/src/dkv/dkv-parser.service.ts
+ - apps/api/src/dkv/dkv-parser.service.spec.ts
+ - apps/api/src/dkv/dkv-parser.validate.ts
+ - apps/api/src/favorites/icon-discovery.service.ts
+ - apps/api/src/ldap/ldap.service.ts
+ - apps/web/src/components/locale-switcher.tsx
+ - apps/web/src/components/locale-switcher.test.tsx
+
+estimate:
+ tokens: 90000
+ raw_tokens: 45000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "sanitizeNextPath weist nach dem Umbau exakt dieselben 54 Codepunkte aus U+0000..U+FFFF ab wie vorher — nachgewiesen, nicht behauptet (D-01)."
+ - "apps/web/src/lib/safe-next.ts enthaelt kein einziges rohes Steuerbyte mehr (vorher 3: Offset 826/828/829)."
+ - "Jeder der 12 Befunde hat eine Entscheidung und eine Begruendung, die benennt, was sonst schiefginge (D-02)."
+ - "Kein Verhalten aendert sich ausser an einer Stelle, an der ein echter Defekt benannt wurde (D-03)."
+ - "Der Open-Redirect-Schutz bleibt unveraendert streng: kein Schema-Allowlist, kein gelockerter Zwei-Zeichen-Praefixtest (D-04)."
+ - "pnpm lint bleibt 5/5 ohne Befund der Stufe error; Biome meldet 433 Warnungen statt 445 und keinen einzigen suppressions/unused-Befund (D-06)."
+ - "Die Testsuiten bleiben gruen und wachsen: apps/web >= 68 Dateien / >= 480 Tests, apps/api 72 Dateien / >= 1137 Tests."
+ artifacts:
+ - apps/web/src/lib/safe-next.ts
+ - apps/web/src/lib/safe-next.test.ts
+ - apps/api/src/dkv/dkv-parser.service.spec.ts
+ - apps/web/src/components/locale-switcher.test.tsx
+ key_links:
+ - "login/page.tsx Zeile 37: window.location.href = sanitizeNextPath(next) — der Rueckgabewert landet ungefiltert in der Adresszeile."
+ - "middleware.ts importiert nur buildNextParam; safe-next.ts muss deshalb weiterhin frei von Node- und DOM-APIs bleiben."
+ - "LdapService.escapeLdapFilterValue speist drei Filterbauten (Zeile 576, 1142, 1228) — die Escape-Kette ist die LDAP-Injection-Sperre."
+ - "i18n/request.ts liest NEXT_LOCALE serverseitig; was locale-switcher.tsx schreibt, muss dort ankommen."
+---
+
+
+Zwoelf fehlerverdaechtige Lint-Befunde aus fuenf Regelklassen einzeln beurteilen und
+abschliessen — jeder mit Urteil und Begruendung (D-02), ohne Verhaltensaenderung
+ausser an nachgewiesenen Defekten (D-03).
+
+Purpose: Der Weiterleitungsschutz nach der Anmeldung haengt heute an drei rohen
+Steuerbytes in einer Zeichenklasse. Das funktioniert, ist aber gegen jeden
+Editor, Formatierer, Minifier und Copy-Paste ungeschuetzt — und der Kommentar
+zwei Zeilen darueber behauptet bereits faelschlich, es seien Escapes. Die
+restlichen elf Befunde sind zu klaeren, damit die Klassen nicht als "ungeprueft"
+im Rueckstand stehen bleiben.
+
+Output: Zwoelf abgeschlossene Befunde, ein beweisbar unveraenderter
+Zeichenraum in safe-next.ts, drei neue bzw. erweiterte Testdateien,
+Biome-Warnungen 445 -> 433.
+
+
+
+@~/.claude/gsd-core/workflows/execute-plan.md
+@~/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/STATE.md
+@CLAUDE.md
+
+@apps/web/src/lib/safe-next.ts
+@apps/web/src/lib/safe-next.test.ts
+@apps/web/src/app/(auth)/login/page.tsx
+@apps/api/src/ldap/ldap.service.ts
+@apps/web/src/components/locale-switcher.tsx
+@biome.json
+
+
+
+Am 2026-09-21 bei der Planung selbst gemessen, Arbeitsverzeichnis sauber:
+
+| Messung | Wert |
+|---|---|
+| `npx biome lint --reporter=json .` Diagnosen gesamt | 446 (445 warning + 1 info) |
+| `pnpm lint` | 5/5 erfolgreich, 0 Befunde der Stufe error |
+| `pnpm type-check` | 4/4 erfolgreich |
+| `pnpm --filter @tessera/web test` | 67 Dateien / 477 Tests |
+| `pnpm --filter @tessera/api test` | 71 Dateien / 1136 Tests |
+
+Die zwoelf Befunde im Auftrag, Regel fuer Regel nachgezaehlt (Testdateien getrennt):
+
+| Regel | gesamt | echter Quelltext | Testdateien |
+|---|---|---|---|
+| `noControlCharactersInRegex` | 3 | 3 | 0 |
+| `useIterableCallbackReturn` | 1 | 1 | 0 |
+| `noGlobalIsNan` | 4 | 4 | 0 |
+| `noAssignInExpressions` | 7 | 3 | 4 |
+| `noDocumentCookie` | 18 | 1 | 17 |
+
+**Rohbytes in `apps/web/src/lib/safe-next.ts` (selbst ausgelesen, nicht uebernommen):**
+Datei 2458 Byte, Zeile 18 lautet byteweise
+`63 6f 6e 73 74 20 46 4f 52 42 49 44 44 45 4e 5f 43 48 41 52 53 5f 52 45 20 3d 20 2f 5b 5c 5c 5c 73 00 2d 1f 7f 5d 2f 3b`.
+Die drei Nicht-Zeilenumbruch-Steuerbytes der Datei sitzen an Offset 826 (0x00),
+828 (0x1F) und 829 (0x7F). Die Zeichenklasse ist also: Backslash, `\s`,
+Bereich U+0000 bis U+001F, dazu U+007F. Die Transkription im Auftrag stimmt.
+
+**Drei Messungen, die den Plan tragen (jeweils mit einer Wegwerf-Datei geprueft und wieder entfernt):**
+
+1. Biome beanstandet Steuerzeichen **auch als Escape**. `\u0000-\u001f` und
+ `\x00-\x1f` erzeugen genau dieselben zwei Warnungen wie die Rohbytes. Das
+ blosse Umschreiben auf Escapes senkt die Warnungszahl also **nicht** — es
+ braucht zusaetzlich einen Unterdrueckungskommentar. U+007F beanstandet Biome
+ nicht; die Regel deckt nur U+0000..U+001F ab. Deshalb sind es 2 Befunde in
+ dieser Datei und nicht 3.
+2. Ein `// biome-ignore` unmittelbar ueber der Regex-Zeile unterdrueckt **beide**
+ Spaltenbefunde dieser Zeile mit einem einzigen Kommentar. Steht der Kommentar
+ dagegen eine Zeile zu hoch, bleibt der urspruengliche Befund stehen **und**
+ Biome meldet zusaetzlich `suppressions/unused` — die Warnungszahl steigt dann.
+ Das ist die gefaehrlichste Falle dieses Vorgangs.
+3. Die Form `for (let m = re.exec(t); m !== null; m = re.exec(t))` wird von
+ `noAssignInExpressions` **nicht** beanstandet. Die drei exec-Schleifen lassen
+ sich also ohne Unterdrueckungskommentar und ohne Verhaltensaenderung
+ umschreiben.
+
+**Der Zeichenraum von `sanitizeNextPath` heute, vollstaendig ausgemessen**
+(U+0000..U+FFFF, Eingabe `'/a' + Zeichen + 'b'`, abgewiesen = Rueckgabe `'/'`):
+genau **54** Codepunkte werden abgewiesen —
+`0x00`-`0x1f`, `0x20`, `0x5c`, `0x7f`, `0xa0`, `0x1680`, `0x2000`-`0x200a`,
+`0x2028`, `0x2029`, `0x202f`, `0x205f`, `0x3000`, `0xfeff`.
+SHA-256 der 65536 Zeichen langen Bitmap aus `'1'`/`'0'`:
+`3d58108b87e4641e506602cc701a66d11ec19cf20551937fada1826f50ecbe64`.
+
+Und vorab bewiesen, ebenfalls ueber alle 65536 Codepunkte: die Escape-Variante
+`/[\\\s\u0000-\u001f\u007f]/` trifft **exakt** dieselbe Menge wie die Variante
+mit Rohbytes. Null Abweichungen.
+
+
+
+Das Urteil je Befund, aus dem gelesenen Quelltext, nicht aus der Regel (D-02):
+
+| # | Datei | Regel | Urteil | Begruendung |
+|---|---|---|---|---|
+| 1 | `apps/web/src/lib/safe-next.ts` (2 Befunde) | `noControlCharactersInRegex` | **Haertung** | Kein Fehler heute, aber die Zeichenklasse des Open-Redirect-Schutzes haengt an drei rohen Bytes, die jedes Werkzeug in der Kette stillschweigend fressen kann — faellt das NUL weg, wird `[\\\s-\u001f\u007f]` daraus, ein Bereich, der ploetzlich andere Zeichen umfasst. |
+| 2 | `apps/api/src/ldap/ldap.service.ts:1662` | `noControlCharactersInRegex` | **Absicht** | `.replace(/\x00/g, '\\00')` ist die von RFC 4515 vorgeschriebene NUL-Maskierung in `escapeLdapFilterValue` — genau dieses Steuerzeichen zu treffen ist der Zweck; wer es entfernt, oeffnet LDAP-Filter-Injection. |
+| 3 | `apps/web/src/app/(portal)/modules/cert-manager/actions.ts:154` | `useIterableCallbackReturn` | **gleichwertig** | Es ist `forEach`, kein `.every`/`.some`/`.map`/`.sort`: der Pfeilausdruck gibt das `undefined` von `FormData.append` zurueck, `forEach` verwirft jeden Rueckgabewert. Rein kosmetisch. |
+| 4-7 | `TenderDetail.tsx` (2x), `InvoiceHistoryTable.tsx`, `ResultsList.tsx` | `noGlobalIsNan` | **gleichwertig** | Alle vier Stellen lauten `isNaN(d.getTime())`. `Date.prototype.getTime()` liefert laut Spezifikation immer `number`, also findet gar keine Umwandlung statt. Der Tausch ist sicher, weil das Argument statisch `number` ist; er lohnt trotzdem, weil `isNaN` bei einer spaeteren Aenderung auf einen String stillschweigend umwandeln wuerde. |
+| 8-10 | `dkv-parser.service.ts:104`, `dkv-parser.validate.ts:139`, `icon-discovery.service.ts:182` | `noAssignInExpressions` | **Absicht** | Alle drei sind die idiomatische Form `while ((m = re.exec(text)) !== null)` mit globalem Regex — kein verrutschtes `=`. Keine der drei Variablen wird nach der Schleife gelesen. |
+| 11 | `apps/web/src/components/locale-switcher.tsx:15` | `noDocumentCookie` | **Haertung** | `path=/` und `max-age=31536000` sind gesetzt und richtig, die Sprache bleibt also erhalten — kein Persistenzdefekt. Es fehlt `SameSite`: das Cookie haengt damit am Browservorgabewert statt an einer Festlegung. Die Cookie-Store-API, die Biome vorschlaegt, fehlt in WebKit und damit im Tauri-Wrapper auf Linux/macOS. |
+
+
+
+
+
+ Task 1: safe-next.ts — Steuerzeichen als Escapes, Zeichenmenge beweisbar unveraendert
+ apps/web/src/lib/safe-next.ts, apps/web/src/lib/safe-next.test.ts
+
+ apps/web/src/lib/safe-next.ts (vollstaendig, inklusive Rohbytes),
+ apps/web/src/lib/safe-next.test.ts (Tests 1-9),
+ apps/web/src/app/(auth)/login/page.tsx Zeile 30-40 (die Senke: window.location.href),
+ apps/web/src/middleware.ts Zeile 1-5 und 94-100 (importiert nur buildNextParam).
+ Umsetzt D-01 und D-04.
+
+
+ Zuerst die Tests, dann der Umbau. Neu in safe-next.test.ts:
+
+ - Test A "die abgewiesene Zeichenmenge ist und bleibt genau diese 54 Codepunkte":
+ Schleife ueber cp = 0 bis 0xffff, Eingabe `'/a' + String.fromCharCode(cp) + 'b'`,
+ abgewiesen genau dann, wenn `sanitizeNextPath(...)` den Wert `'/'` liefert.
+ Die gesammelte Liste der abgewiesenen Codepunkte muss exakt gleich sein zu
+ der im Plan gemessenen Liste (0x00-0x1f, 0x20, 0x5c, 0x7f, 0xa0, 0x1680,
+ 0x2000-0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff — 54 Stueck).
+ Die Erwartung wird als Liste hart hinterlegt, nicht aus einem Praedikat
+ erzeugt: eine aus derselben Regel abgeleitete Erwartung wuerde jede
+ Aenderung der Regel mitmachen und damit nichts beweisen.
+ - Test B "die Datei enthaelt kein rohes Steuerzeichen": liest die eigene
+ Quelldatei ueber `node:fs` als Buffer und zaehlt Bytes < 0x20 ausser 0x0a,
+ plus 0x7f. Erwartung: 0.
+ - Test C "der Zwei-Zeichen-Praefixtest bleibt scharf": `'//host'`,
+ `'/\\host'` und `'/'` + ein U+0000 landen weiterhin auf `'/'`.
+
+ Test A muss **vor** dem Umbau geschrieben werden und **gegen die heutige
+ Fassung gruen** sein — das ist der Sinn: er haelt den Ist-Zustand fest.
+ Test B ist vor dem Umbau rot (3 Bytes) und nach dem Umbau gruen.
+
+
+ Schritt 1 — Ausgangswert sichern, bevor irgendetwas geaendert wird. Test A und
+ Test C in safe-next.test.ts ergaenzen, Test B ebenfalls ergaenzen aber zunaechst
+ sein Rotsein zur Kenntnis nehmen. `npx vitest run src/lib/safe-next.test.ts` in
+ apps/web laufen lassen: A und C gruen, B rot. Zusaetzlich den SHA-256 der
+ Bitmap aus Test A ausgeben lassen oder einmalig berechnen und notieren; er muss
+ `3d58108b87e4641e506602cc701a66d11ec19cf20551937fada1826f50ecbe64` lauten.
+ Weicht er ab, sofort anhalten und melden — dann ist der Arbeitsbaum nicht der,
+ auf dem dieser Plan gemessen wurde.
+
+ Schritt 2 — den Umbau. In safe-next.ts die Zeile mit `FORBIDDEN_CHARS_RE` so
+ ersetzen, dass die Zeichenklasse aus Backslash, `\s`, dem Bereich `\u0000` bis
+ `\u001f` und `\u007f` besteht, geschrieben ausschliesslich mit
+ Unicode-Escapes. Danach enthaelt die Datei keine Rohbytes mehr (D-01). Die
+ Reihenfolge der Klassenglieder unveraendert lassen.
+
+ Schritt 3 — den Kommentar berichtigen. Der bestehende Zweizeiler ueber der
+ Konstante behauptet heute, die Zeichen seien "als Unicode-Escapes, nicht \x,
+ weil safe-next.ts auch am Edge laeuft" geschrieben. Das war doppelt falsch:
+ sie waren keine Escapes, und zwischen `\x` und `\u` gibt es zur Laufzeit
+ keinerlei Unterschied, am Edge so wenig wie im Browser. Neu formulieren: was
+ die Klasse trifft (Backslash, jegliches Whitespace, U+0000 bis U+001F, U+007F),
+ und die tatsaechliche Randbedingung der Datei — sie wird von der
+ Edge-Middleware und vom Client importiert und darf deshalb keine Node- oder
+ DOM-APIs verwenden. Nicht behaupten, Escapes seien am Edge noetig.
+
+ Schritt 4 — die beiden Biome-Befunde unterdruecken. Unmittelbar, ohne
+ Leerzeile, ueber die Regex-Zeile einen `biome-ignore`-Kommentar fuer
+ `lint/suspicious/noControlCharactersInRegex` setzen. Begruendung im
+ Repo-Stil (siehe die vier vorhandenen Beispiele, deutsch, nennt die Folge):
+ dass die Regel den Bereich auch in Escape-Schreibweise beanstandet, dass
+ genau dieser Bereich der Zweck des Ausdrucks ist, und dass er den
+ Weiterleitungsschutz T-gyd-01 traegt. Die Platzierung ist kritisch: eine
+ Zeile zu hoch und Biome meldet den alten Befund weiter und zusaetzlich einen
+ unbenutzten Unterdrueckungskommentar.
+
+ Schritt 5 — beweisen. Test A erneut laufen lassen; er muss ohne jede Aenderung
+ an der Erwartungsliste gruen bleiben. Test B ist jetzt gruen.
+
+ WERKZEUGFALLE, bei der Planung selbst hineingetappt: Die Schreibwerkzeuge
+ (Write, Edit) behandeln ihren Inhalt wie eine JSON-Zeichenkette und wandeln
+ eine Sequenz aus Backslash, kleinem `u` und vier Hexziffern **still in das
+ Zeichen selbst um**. Wer die neue Regex-Zeile auf diesem Weg schreibt,
+ erzeugt exakt wieder die rohen Steuerbytes, die dieser Task beseitigen soll —
+ Test B faellt dann durch, und beim fluechtigen Hinsehen sieht die Datei
+ richtig aus. Abhilfe: die Zeile mit einem Platzhalter statt des Backslashs
+ schreiben und den Platzhalter anschliessend per `python3` durch `chr(92)`
+ ersetzen, oder die Zeile gleich vollstaendig per `python3` in die Datei
+ schreiben. Danach zwingend der Byte-Scan aus ``, bevor irgendetwas
+ als fertig gilt.
+
+ Was NICHT passiert (D-04): der Zwei-Zeichen-Praefixtest bleibt wie er ist, es
+ kommt keine Liste bekannter Schemata hinzu, `MAX_NEXT_PATH_LENGTH` bleibt bei
+ 2048, die Reihenfolge der Pruefungen bleibt, die bestehenden Tests 1-9 werden
+ nicht angefasst. Keine Abhaengigkeit, keine Formatierung ausserhalb dieser
+ Datei (D-05).
+
+
+ cd apps/web && npx vitest run src/lib/safe-next.test.ts 2>&1 | tail -6
+ python3 -c "b=open('apps/web/src/lib/safe-next.ts','rb').read(); c=[i for i,x in enumerate(b) if (x<0x20 and x!=0x0a) or x==0x7f]; print('CTRL_BYTES', len(c), c); assert len(c)==0, 'rohe Steuerbytes noch vorhanden'"
+ npx biome lint apps/web/src/lib/safe-next.ts 2>&1 | tail -3
+
+
+ Vitest meldet `Test Files 1 passed`, Tests >= 12 passed, kein failed.
+ Der Byte-Scan gibt `CTRL_BYTES 0 []` aus und bricht nicht ab.
+ Biome meldet fuer die Datei `Found 0 warnings` (weder ein
+ Steuerzeichen-Befund noch ein unbenutzter Unterdrueckungskommentar).
+ Test A erzwingt weiterhin exakt 54 abgewiesene Codepunkte — dieselbe Liste
+ wie vor dem Umbau, damit ist der Zeichenraum nachweislich identisch (D-01).
+ Die Tests 5 bis 9 aus der Bestandsdatei laufen unveraendert durch (D-04).
+
+ Eine Zeile Regex plus Tests; jederzeit per git revert zurueckzunehmen, und die Testdatei haelt den Ist-Zustand fest.
+
+
+
+ Task 2: isNaN und forEach — vier plus ein gleichwertiger Tausch
+ apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx, apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx, apps/web/src/app/(portal)/modules/cert-manager/actions.ts
+
+ TenderDetail.tsx Zeile 40-70, ResultsList.tsx Zeile 45-55,
+ InvoiceHistoryTable.tsx Zeile 25-40, cert-manager/actions.ts Zeile 148-160.
+ Umsetzt D-02 und D-03.
+
+
+ Fuenf Stellen, alle als gleichwertig beurteilt — die Aenderung ist Haertung,
+ kein Defektbehebung, und so ist sie auch in der Zusammenfassung zu benennen
+ (D-02: das offene Eingestaendnis ist das erwuenschte Ergebnis, das stille
+ Umdeuten zum Fehler nicht).
+
+ Vier Stellen `isNaN(d.getTime())` auf `Number.isNaN(d.getTime())` umstellen:
+ TenderDetail.tsx Zeile 44 und Zeile 65, InvoiceHistoryTable.tsx Zeile 33,
+ ResultsList.tsx Zeile 51. Der Tausch ist deshalb sicher, weil `getTime()`
+ immer `number` liefert — die Umwandlung, vor der die Regel warnt, findet an
+ diesen Stellen gar nicht statt. Der Nutzen liegt in der Zukunft: sobald dort
+ einmal ein String aus einer API oder einem Formular ankaeme, wuerde das
+ globale `isNaN` ihn stillschweigend umwandeln und eine kaputte Eingabe als
+ gueltiges Datum durchwinken. Die bestehenden Rueckfallwerte
+ (`noDeadlineLabel`, `'–'`, `isoString`) unveraendert lassen, ebenso den
+ Kommentar ueber Zeile 33 in InvoiceHistoryTable.tsx (WR-03), der weiterhin
+ stimmt. Die vorhandenen `biome-ignore`-Kommentare fuer
+ `useExhaustiveDependencies` in InvoiceHistoryTable.tsx Zeile 84 und
+ ResultsList.tsx Zeile 141 nicht beruehren.
+
+ In cert-manager/actions.ts Zeile 154 die Pfeilfunktion mit Kurzform-Rumpf auf
+ einen Block-Rumpf umstellen, sodass der Rueckgabewert von `FormData.append`
+ nicht mehr aus der Schleifenfunktion herausgereicht wird. `forEach` verwirft
+ ihn ohnehin — deshalb ist das eine Lesbarkeitsaenderung ohne jede Wirkung.
+ Eine Umstellung auf `for...of` ist ebenso zulaessig, aendert aber mehr Zeilen
+ als noetig. Reihenfolge der `form.append`-Aufrufe strikt beibehalten: erst
+ alle Dateien, dann `outputFormat`, dann optional `password` — der Server
+ liest das Multipart-Formular in dieser Reihenfolge.
+
+
+ npx biome lint --reporter=json --max-diagnostics=5000 . 2>/dev/null | python3 -c "import sys,json,collections; d=json.load(sys.stdin); c=collections.Counter(x['category'] for x in d['diagnostics']); print('noGlobalIsNan', c['lint/suspicious/noGlobalIsNan']); print('useIterableCallbackReturn', c['lint/suspicious/useIterableCallbackReturn']); assert c['lint/suspicious/noGlobalIsNan']==0 and c['lint/suspicious/useIterableCallbackReturn']==0"
+ pnpm --filter @tessera/web test 2>&1 | tail -5
+ pnpm type-check 2>&1 | tail -4
+
+
+ Die Zaehlung aus der Biome-JSON-Ausgabe meldet `noGlobalIsNan 0` und
+ `useIterableCallbackReturn 0` und bricht nicht ab. Die Zahlen stammen aus
+ dem Feld `category` der Diagnosen, nicht aus einer Textsuche im Quelltext —
+ ein Kommentar im Code kann das Tor deshalb nicht verfaelschen.
+ `pnpm --filter @tessera/web test` meldet mindestens 67 Dateien und
+ mindestens 477 Tests, alle passed.
+ `pnpm type-check` meldet 4/4 erfolgreich.
+ Kein Rueckfallwert und keine append-Reihenfolge wurde veraendert (D-03).
+
+
+
+
+ Task 3: exec-Schleifen, LDAP-NUL-Maskierung und das Sprachumschalter-Cookie
+ apps/api/src/dkv/dkv-parser.service.ts, apps/api/src/dkv/dkv-parser.service.spec.ts, apps/api/src/dkv/dkv-parser.validate.ts, apps/api/src/favorites/icon-discovery.service.ts, apps/api/src/ldap/ldap.service.ts, apps/web/src/components/locale-switcher.tsx, apps/web/src/components/locale-switcher.test.tsx
+
+ apps/api/src/dkv/dkv-parser.service.ts Zeile 34-115 (parsePdf, extractText, parseDkvText),
+ apps/api/src/dkv/dkv-parser.validate.ts Zeile 128-150,
+ apps/api/src/favorites/icon-discovery.service.ts Zeile 177-190,
+ apps/api/src/ldap/ldap.service.ts Zeile 1650-1675 (escapeLdapFilterValue und die NOTE darunter),
+ apps/web/src/components/locale-switcher.tsx,
+ apps/web/src/i18n/request.ts,
+ apps/api/src/tenders/tender-mail.service.spec.ts (Vorbild fuer vi.mock auf ein Paket),
+ apps/api/src/favorites/icon-discovery.service.spec.ts (deckt parseAttributes ueber discoverFavoriteIconUrl ab).
+ Umsetzt D-02, D-03 und D-05.
+
+
+ Zwei neue Tests, beide **vor** der jeweiligen Aenderung geschrieben und gegen
+ die heutige Fassung gruen:
+
+ - `apps/api/src/dkv/dkv-parser.service.spec.ts` (neu): `pdf-parse` per
+ `vi.mock` durch eine Attrappe ersetzen, deren `getText()` einen fest
+ hinterlegten DKV-Beispieltext mit **zwei** Fahrzeugbloecken liefert
+ (`VEHICLE: ... CARD NO.: ...`, je eine tabulatorgetrennte Transaktionszeile,
+ dazu eine Rechnungsnummer im Format DD/DDDDDDDDD/DDD gefolgt von einem
+ Datum in der naechsten Zeile). `destroy()` als no-op. Ein Test:
+ `parsePdf` liefert zwei Fahrzeugbloecke mit den erwarteten Kennzeichen und
+ Kartennummern, plus die erwartete Rechnungsnummer und das erwartete
+ Rechnungsdatum. Dieser Test ist das Rueckfall-Tor fuer den
+ Schleifenumbau — `parseDkvText` hat heute keine eigene Abdeckung.
+ - `apps/web/src/components/locale-switcher.test.tsx` (neu): den Setter von
+ `document.cookie` per `Object.defineProperty` durch eine Attrappe ersetzen,
+ die Komponente rendern, den Knopf klicken und die genau **eine**
+ geschriebene Zeichenkette pruefen: sie enthaelt `NEXT_LOCALE=en`,
+ `path=/`, `max-age=31536000` und — nach der Aenderung — `SameSite=Lax`.
+ Vor der Aenderung faellt nur die SameSite-Zusicherung durch; das ist
+ beabsichtigt und zeigt genau die Luecke.
+
+
+ Teil A — die drei exec-Schleifen (Urteil: Absicht, kein verrutschtes `=`).
+ Alle drei sind die korrekte Standardform. Sie werden trotzdem umgeschrieben,
+ weil es eine verhaltensgleiche Schreibweise gibt, die ohne
+ Unterdrueckungskommentar auskommt: die Zuweisung wandert in den Kopf einer
+ `for`-Schleife, deren Initialisierung den ersten `exec`-Aufruf macht, deren
+ Bedingung auf `null` prueft und deren Fortschaltung den naechsten
+ `exec`-Aufruf macht. Bei Planung nachgemessen: diese Form loest die Regel
+ nicht aus. Die Abfolge der `exec`-Aufrufe und der Rumpfdurchlaeufe ist
+ identisch, der globale Regex behaelt seinen `lastIndex`-Fortschritt.
+ Betroffen: `dkv-parser.service.ts` Zeile 103/104 (`match`),
+ `dkv-parser.validate.ts` Zeile 138/139 (`match`),
+ `icon-discovery.service.ts` Zeile 180/182 (`m`). In allen drei Faellen wird
+ die Variable nach der Schleife nicht mehr gelesen — nachgeprueft —, die
+ vorgezogene `let`-Deklaration entfaellt also ersatzlos. Die Regex-Literale
+ selbst, die Rumpfinhalte und die `.trim()`-Aufrufe bleiben Zeichen fuer
+ Zeichen unveraendert. `dkv-parser.validate.ts` ist ein eigenstaendiges
+ Pruefskript, das nur per `node --experimental-strip-types` laeuft; es wird
+ mitgeaendert, damit es nicht von der Service-Fassung abdriftet, und von
+ `pnpm type-check` mit abgedeckt.
+
+ Teil B — die NUL-Maskierung im LDAP-Filter (Urteil: Absicht). An
+ `LdapService.escapeLdapFilterValue` wird **nichts** geaendert. Der Ausdruck
+ trifft U+0000 absichtlich, weil RFC 4515 genau dieses Zeichen als `\00`
+ verlangt; die Funktion ist die Injection-Sperre fuer die drei Filterbauten in
+ Zeile 576, 1142 und 1228. Stattdessen unmittelbar ueber die betroffene Zeile
+ ein `biome-ignore` fuer `lint/suspicious/noControlCharactersInRegex` setzen,
+ mit deutscher Begruendung, die den RFC und die Folge des Entfernens nennt
+ (ein unmaskiertes NUL kann den Filter beim Verzeichnisserver abschneiden).
+ Die Escape-Reihenfolge — Backslash zuerst, dann Stern, Klammer auf, Klammer
+ zu, NUL — nicht antasten: der Backslash muss zuerst kommen, sonst werden die
+ eigenen Ersetzungen erneut maskiert.
+
+ Teil C — das Sprachumschalter-Cookie (Urteil: Haertung). `path=/` stimmt und
+ `max-age=31536000` stimmt; `i18n/request.ts` liest `NEXT_LOCALE`
+ serverseitig und bekommt das Cookie heute auch — es liegt also kein
+ Persistenzdefekt vor, und das ist so zu benennen. Ergaenzt wird `SameSite=Lax`,
+ damit die Uebertragung bei der Seitennavigation festgelegt ist statt vom
+ Vorgabewert des jeweiligen Browsers abzuhaengen; `Lax` und nicht `Strict`,
+ weil das Cookie bei einer Navigation von aussen auf die Seite mitkommen muss,
+ sonst faellt die Oberflaeche auf Deutsch zurueck. Kein `Secure` setzen: die
+ Entwicklungsumgebung laeuft auf `http://localhost:3000`, und das Cookie traegt
+ keine Berechtigung, nur die Sprachwahl. Der Schreibzugriff bleibt
+ `document.cookie`; darueber ein `biome-ignore` fuer
+ `lint/suspicious/noDocumentCookie` mit der Begruendung, dass die von Biome
+ vorgeschlagene Cookie-Store-API in WebKit fehlt und die Sprachumschaltung
+ damit im Tauri-Wrapper auf Linux und macOS wirkungslos waere.
+
+ Fuer alle drei Unterdrueckungskommentare in diesem Vorgang gilt: sie stehen
+ direkt ueber der beanstandeten Zeile, ohne Leerzeile dazwischen. Sitzt einer
+ falsch, bleibt der alte Befund stehen und Biome meldet zusaetzlich einen
+ unbenutzten Kommentar — die Warnungszahl steigt dann statt zu fallen und das
+ Tor in `` schlaegt fehl.
+
+ Keine neue Abhaengigkeit, keine Versionsanhebung, keine Formatierung ueber
+ die genannten Dateien hinaus (D-05).
+
+
+ pnpm --filter @tessera/api test 2>&1 | tail -5
+ pnpm --filter @tessera/web test 2>&1 | tail -5
+ pnpm type-check 2>&1 | tail -4
+ npx biome lint --reporter=json --max-diagnostics=5000 . 2>/dev/null | python3 -c "import sys,json,collections; d=json.load(sys.stdin); c=collections.Counter(x['category'] for x in d['diagnostics']); s=collections.Counter(x['severity'] for x in d['diagnostics']); print('total',len(d['diagnostics']),'sev',dict(s)); print({k:c[k] for k in ['lint/suspicious/noControlCharactersInRegex','lint/suspicious/useIterableCallbackReturn','lint/suspicious/noGlobalIsNan','lint/suspicious/noAssignInExpressions','lint/suspicious/noDocumentCookie','suppressions/unused']}); assert c['lint/suspicious/noControlCharactersInRegex']==0; assert c['lint/suspicious/noAssignInExpressions']==4; assert c['lint/suspicious/noDocumentCookie']==17; assert c['suppressions/unused']==0; assert s['error']==0; assert len(d['diagnostics'])==434"
+ pnpm lint 2>&1 | tail -4
+
+
+ `pnpm --filter @tessera/api test` meldet 72 Dateien und mindestens 1137 Tests,
+ alle passed (die 71/1136 von vorher plus die neue Parser-Spezifikation).
+ `pnpm --filter @tessera/web test` meldet mindestens 68 Dateien und mindestens
+ 480 Tests, alle passed.
+ `pnpm type-check` meldet 4/4 erfolgreich.
+ Die Biome-Zaehlung meldet `total 434`, `sev {'warning': 433, 'info': 1}` und
+ bricht bei keiner der sechs Zusicherungen ab: die drei Steuerzeichen-Befunde
+ sind weg, `noAssignInExpressions` steht bei 4 (die vier verbliebenen liegen
+ ausschliesslich in Testdateien und waren nie im Auftrag),
+ `noDocumentCookie` steht bei 17 (ebenfalls samtlich Testdateien), es gibt
+ keinen unbenutzten Unterdrueckungskommentar und keinen Befund der Stufe error.
+ 434 = 446 minus die zwoelf bearbeiteten Befunde — die Zahl faellt also genau
+ um das, was bearbeitet wurde, und waechst nirgends sonst (D-06).
+ `pnpm lint` meldet `5 successful, 5 total`.
+ `escapeLdapFilterValue` ist zeichengleich zur Ausgangsfassung geblieben (D-03).
+
+ Mechanische Umschreibungen plus zwei Kommentare; die beiden neuen Spezifikationen halten das Verhalten fest, ein Zurueckrollen ist folgenlos.
+
+
+
+
+
+
+ASVS-Stufe 1, blockierend ab Schweregrad high. Keine Paketinstallation in diesem
+Vorgang (D-05), deshalb kein Paket-Legitimitaetstor.
+
+## Trust Boundaries
+
+| Grenze | Beschreibung |
+|---|---|
+| Adresszeile des Browsers -> `next`-Parameter -> `sanitizeNextPath` -> `window.location.href` | Vollstaendig angreiferkontrollierter Wert. In `login/page.tsx:37` wird der Rueckgabewert **direkt** der Adresszeile zugewiesen; zwischen der Funktion und der Navigation liegt nichts mehr. |
+| Browser -> Edge-Middleware -> `buildNextParam` | Die Middleware importiert nur `buildNextParam` und **nicht** `sanitizeNextPath`. Der Schutz laeuft also ausschliesslich im Client. Die Doppelnutzung der Datei bedeutet praktisch nur eine Auflage: keine Node- und keine DOM-APIs in safe-next.ts. Zwischen `\x`- und `\u`-Escapes gibt es zur Laufzeit keinen Unterschied, am Edge so wenig wie im Browser — die gegenteilige Behauptung im heutigen Kommentar ist gegenstandslos und wird in Task 1 berichtigt. |
+| Nutzereingabe / Gruppen-DN aus der Datenbank -> `escapeLdapFilterValue` -> LDAP-Filterzeichenkette -> Verzeichnisserver | Die Maskierung ist die einzige Sperre gegen Filter-Injection; das Verzeichnis wird nur lesend gebunden. |
+| Client -> `document.cookie` NEXT_LOCALE -> `cookies().get('NEXT_LOCALE')` in `i18n/request.ts` | Vom Nutzer setzbarer Wert ohne Berechtigung; steuert nur die Sprachauswahl. |
+| DKV-PDF aus dem Postfach -> `parsePdf` -> Fahrzeug- und Transaktionsdaten | Fremdformat; ein stiller Parserfehler erzeugt falsche Rechnungsdaten statt eines Abbruchs. |
+
+## STRIDE Threat Register
+
+| Threat ID | Kategorie | Komponente | Schweregrad | Disposition | Mitigation Plan |
+|---|---|---|---|---|---|
+| T-i8x-01 | Spoofing | `FORBIDDEN_CHARS_RE` in `safe-next.ts` | high | mitigate | Faellt beim Umschreiben ein Klassenglied weg oder verrutscht der Bereichsstrich, veraendert sich die abgewiesene Zeichenmenge unbemerkt — und genau diese Klasse haelt Whitespace-Schmuggel und Steuerzeichen aus dem Ziel heraus, das `login/page.tsx:37` in die Adresszeile schreibt. Task 1 haelt die Menge mit einem Test ueber alle 65536 Codepunkte gegen eine hart hinterlegte Liste von 54 Codepunkten fest, geschrieben **vor** dem Umbau und gruen gegen die Altfassung. Zusaetzlich vorab bewiesen: die Escape-Variante trifft dieselbe Menge, null Abweichungen. |
+| T-i8x-02 | Tampering | Zwei-Zeichen-Praefixtest in `sanitizeNextPath` | high | mitigate | Ein "Vereinfachen" von `raw[1] === '/' \|\| raw[1] === '\\'` wuerde protokoll-relative Ziele wie `//boeser.host` wieder durchlassen, die der Browser als absolute Adresse liest. D-04 verbietet die Aenderung; Task 1 aendert ausschliesslich die Regex-Zeile und die zwei Kommentarzeilen, Test C und die Bestandstests 7 und 8 sichern den Praefixtest ab. |
+| T-i8x-03 | Tampering | `LdapService.escapeLdapFilterValue` | high | mitigate | Wer das als "Steuerzeichen-Befund" behandelt und den NUL-Ersatz entfernt oder die Reihenfolge der Ersetzungen dreht, oeffnet LDAP-Filter-Injection bzw. laesst den Backslash die eigenen Ersetzungen doppelt maskieren. Task 3 Teil B aendert die Funktion nicht, sondern setzt nur einen Unterdrueckungskommentar; `` fordert Zeichengleichheit zur Ausgangsfassung. |
+| T-i8x-04 | Tampering | `parseDkvText` und `parseAttributes`, Umbau der exec-Schleifen | medium | mitigate | Ein falsch gesetzter Fortschaltungsausdruck ergibt eine Endlosschleife oder ueberspringt jeden zweiten Treffer — bei der Rechnungsauswertung faellt das erst an falschen Zahlen auf. `parseDkvText` hat heute keine Abdeckung; Task 3 legt dafuer eine Spezifikation mit zwei Fahrzeugbloecken an, **bevor** umgebaut wird. `parseAttributes` ist ueber `icon-discovery.service.spec.ts` ("extracts the apple-touch-icon from page HTML") bereits abgedeckt. |
+| T-i8x-05 | Information Disclosure | NEXT_LOCALE-Cookie | low | mitigate | Ohne ausdrueckliches `SameSite` haengt die Uebertragung am Vorgabewert des Browsers; ein Browser ohne Lax-Vorgabe schickt das Cookie auch seitenuebergreifend mit. Das Cookie traegt keine Berechtigung, der Schaden ist entsprechend gering. Task 3 Teil C setzt `SameSite=Lax` und sichert die vollstaendige geschriebene Zeichenkette mit einem Test ab. |
+| T-i8x-06 | Repudiation | Unterdrueckungskommentare in allen drei Dateien | low | mitigate | Ein falsch platzierter `biome-ignore` laesst den urspruenglichen Befund stehen und erzeugt zusaetzlich `suppressions/unused` — die sicherheitsrelevante Regel waere dann still weiter offen, waehrend die Zusammenfassung sie als erledigt fuehrt. Das Tor in Task 3 zaehlt `suppressions/unused` ausdruecklich auf 0 und die Gesamtzahl auf exakt 434. |
+| T-i8x-07 | Tampering | Messtor selbst | low | accept | Die Zaehlungen lesen das Feld `category` aus Biomes JSON-Ausgabe, nie den Quelltext per Textsuche. Ein Regelname im Unterdrueckungskommentar kann das Tor deshalb nicht verfaelschen. Kein weiterer Aufwand noetig. |
+
+
+
+
+
+Nach allen drei Tasks, im sauberen Arbeitsbaum, vom Projektwurzelverzeichnis aus:
+
+1. `pnpm lint` — erwartet `5 successful, 5 total`.
+2. Die Biome-JSON-Zaehlung aus Task 3 — erwartet `total 434`,
+ `sev {'warning': 433, 'info': 1}`, `noControlCharactersInRegex 0`,
+ `useIterableCallbackReturn 0`, `noGlobalIsNan 0`, `noAssignInExpressions 4`,
+ `noDocumentCookie 17`, `suppressions/unused 0`.
+3. `pnpm type-check` — erwartet `4 successful, 4 total`.
+4. `pnpm --filter @tessera/web test` — erwartet >= 68 Dateien, >= 480 Tests, 0 failed.
+5. `pnpm --filter @tessera/api test` — erwartet 72 Dateien, >= 1137 Tests, 0 failed.
+6. Byte-Scan von `apps/web/src/lib/safe-next.ts` — erwartet `CTRL_BYTES 0 []`.
+7. `git status --porcelain -- apps packages` — erwartet genau die 13 Dateien aus
+ `files_modified`, keine weitere. Schlaegt das fehl, wurde formatiert oder
+ umgeraeumt, was D-05 verbietet. (`.planning/` ist hier bewusst
+ ausgeklammert: Plan und Zusammenfassung gehoeren dazu.)
+8. `git diff -- package.json '*/package.json' pnpm-lock.yaml` — erwartet leer:
+ keine neue Abhaengigkeit, keine Versionsanhebung (D-05).
+
+Kein laufender Stack noetig: alle zwoelf Befunde liessen sich statisch beurteilen,
+und die zwei riskanten Stellen (Zeichenraum des Weiterleitungsschutzes,
+Parser-Schleifen) sind mit Tests besser abgedeckt als mit einer Bedienprobe.
+Die Aenderung am Sprachumschalter ist im Browser nachpruefbar, falls der Nutzer
+es wuenscht — sie ist es nicht wert, dafuer einen Stack hochzufahren.
+
+
+
+
+
+- Zwoelf Befunde abgeschlossen, jeder mit Urteil und einer Begruendung, die
+ benennt, was ohne die Entscheidung schiefginge (D-02). Die sieben als
+ gleichwertig bzw. absichtlich beurteilten Stellen sind als solche benannt und
+ nicht nachtraeglich zum Fehler erklaert.
+- `sanitizeNextPath` weist nach dem Umbau exakt dieselben 54 Codepunkte ab wie
+ vorher, nachgewiesen ueber alle 65536 Codepunkte aus U+0000..U+FFFF (D-01).
+- `apps/web/src/lib/safe-next.ts` enthaelt kein rohes Steuerbyte mehr, und der
+ Kommentar beschreibt jetzt zutreffend, was in der Zeile darunter steht (D-01).
+- Der Weiterleitungsschutz wurde nicht gelockert: Praefixtest, Laengenbegrenzung,
+ Pruefreihenfolge und die bewusst fehlende Schema-Liste unveraendert (D-04).
+- Ausser den drei Stellen, an denen eine Haertung ausdruecklich beschlossen wurde
+ (Escape-Schreibweise, `Number.isNaN`, `SameSite=Lax`), aendert sich kein
+ beobachtbares Verhalten (D-03).
+- Keine neue Abhaengigkeit, keine Versionsanhebung, keine Formatierung ausserhalb
+ der 13 genannten Dateien (D-05).
+- `pnpm lint` bleibt 5/5 bei 0 Befunden der Stufe error; die Warnungszahl faellt
+ von 445 auf 433 und waechst an keiner anderen Stelle (D-06).
+- `noArrayIndexKey` und `noNonNullAssertion` wurden nicht angefasst — sie gehoeren
+ in den Folgevorgang. `noUselessSwitchCase` bleibt der dokumentierte
+ Nicht-Fix aus 260921-bi2.
+
+
+
+
diff --git a/apps/web/src/lib/safe-next.test.ts b/apps/web/src/lib/safe-next.test.ts
index f288d79..13d5397 100644
--- a/apps/web/src/lib/safe-next.test.ts
+++ b/apps/web/src/lib/safe-next.test.ts
@@ -1,6 +1,30 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { buildNextParam, sanitizeNextPath } from './safe-next';
+// Die 54 Codepunkte, die `sanitizeNextPath` bei der Planung von quick-260921-i8x
+// ueber alle 65536 Codepunkte aus U+0000..U+FFFF gemessen abweist (SHA-256 der
+// Bitmap: 3d58108b87e4641e506602cc701a66d11ec19cf20551937fada1826f50ecbe64).
+// Hart hinterlegt, nicht aus der Regel abgeleitet (D-01): eine aus derselben
+// Regel erzeugte Erwartung wuerde jede Aenderung der Regel mitmachen und
+// bewiese damit nichts.
+const EXPECTED_REJECTED_CODEPOINTS: number[] = [
+ ...Array.from({ length: 0x20 }, (_, i) => i), // 0x00-0x1f
+ 0x20,
+ 0x5c,
+ 0x7f,
+ 0xa0,
+ 0x1680,
+ ...Array.from({ length: 0x200a - 0x2000 + 1 }, (_, i) => 0x2000 + i), // 0x2000-0x200a
+ 0x2028,
+ 0x2029,
+ 0x202f,
+ 0x205f,
+ 0x3000,
+ 0xfeff,
+];
+
/**
* safe-next.test — Ruecksprung nach Anmeldung (quick-260917-gyd).
*
@@ -70,4 +94,34 @@ describe('sanitizeNextPath', () => {
expect(sanitizeNextPath('/login/')).toBe('/');
expect(sanitizeNextPath('/loginhistory')).toBe('/loginhistory');
});
+
+ it('Test A: die abgewiesene Zeichenmenge ist und bleibt genau diese 54 Codepunkte (T-i8x-01)', () => {
+ const rejected: number[] = [];
+ for (let cp = 0; cp <= 0xffff; cp++) {
+ const input = `/a${String.fromCharCode(cp)}b`;
+ if (sanitizeNextPath(input) === '/') {
+ rejected.push(cp);
+ }
+ }
+ expect(rejected).toEqual(EXPECTED_REJECTED_CODEPOINTS);
+ expect(rejected).toHaveLength(54);
+ });
+
+ it('Test B: die Datei enthaelt kein rohes Steuerzeichen', () => {
+ const filePath = join(import.meta.dirname, 'safe-next.ts');
+ const bytes = readFileSync(filePath);
+ let count = 0;
+ for (const byte of bytes) {
+ if ((byte < 0x20 && byte !== 0x0a) || byte === 0x7f) {
+ count++;
+ }
+ }
+ expect(count).toBe(0);
+ });
+
+ it('Test C: der Zwei-Zeichen-Praefixtest bleibt scharf', () => {
+ expect(sanitizeNextPath('//host')).toBe('/');
+ expect(sanitizeNextPath('/\\host')).toBe('/');
+ expect(sanitizeNextPath(`/${String.fromCharCode(0x00)}`)).toBe('/');
+ });
});
diff --git a/apps/web/src/lib/safe-next.ts b/apps/web/src/lib/safe-next.ts
index 4daa681..6a2afbb 100644
Binary files a/apps/web/src/lib/safe-next.ts and b/apps/web/src/lib/safe-next.ts differ