--- 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. Create `.planning/quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/260921-i8x-SUMMARY.md` when done. Die Zusammenfassung enthaelt die Urteilstabelle aus `` mit dem tatsaechlich Umgesetzten je Zeile, die Vorher-/Nachher-Zahlen aus Abschnitt `` und — ausdruecklich — den Satz, dass sieben der zwoelf Befunde keine Fehler waren.