fix(web): Steuerzeichen in safe-next.ts als Unicode-Escapes statt Rohbytes

FORBIDDEN_CHARS_RE in sanitizeNextPath bestand aus drei rohen Steuerbytes
(NUL, US, DEL) statt Escapes - jeder Editor, Formatierer oder Minifier in
der Kette kann solche Bytes stillschweigend verschlucken. Umgeschrieben auf
Unicode-Escapes fuer den Bereich U+0000 bis U+001F und U+007F. Zeichenmenge
ueber alle 65536 Codepunkte aus U+0000 bis U+FFFF als unveraendert
nachgewiesen (54 abgewiesene Codepunkte, Bitmap-SHA-256
3d58108b87e4641e506602cc701a66d11ec19cf20551937fada1826f50ecbe64 vor und
nach dem Umbau identisch). Kommentar korrigiert: die vorherige Behauptung,
Escapes seien "am Edge" noetig, war falsch - zwischen den beiden
Escape-Schreibweisen gibt es zur Laufzeit keinen Unterschied.

Quick-Vorgang 260921-i8x, Task 1/3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 13:26:25 +02:00
parent 287799a6fc
commit f85c91be01
3 changed files with 584 additions and 0 deletions
@@ -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."
---
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<measured_baseline_at_planning_time>
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.
</measured_baseline_at_planning_time>
<verdicts>
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. |
</verdicts>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: safe-next.ts — Steuerzeichen als Escapes, Zeichenmenge beweisbar unveraendert</name>
<files>apps/web/src/lib/safe-next.ts, apps/web/src/lib/safe-next.test.ts</files>
<read_first>
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.
</read_first>
<behavior>
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.
</behavior>
<action>
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 `<verify>`, 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).
</action>
<verify>
<automated>cd apps/web &amp;&amp; npx vitest run src/lib/safe-next.test.ts 2>&amp;1 | tail -6</automated>
<automated>python3 -c "b=open('apps/web/src/lib/safe-next.ts','rb').read(); c=[i for i,x in enumerate(b) if (x&lt;0x20 and x!=0x0a) or x==0x7f]; print('CTRL_BYTES', len(c), c); assert len(c)==0, 'rohe Steuerbytes noch vorhanden'"</automated>
<automated>npx biome lint apps/web/src/lib/safe-next.ts 2>&amp;1 | tail -3</automated>
</verify>
<done>
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).
</done>
<reversibility rating="reversible">Eine Zeile Regex plus Tests; jederzeit per git revert zurueckzunehmen, und die Testdatei haelt den Ist-Zustand fest.</reversibility>
</task>
<task type="auto">
<name>Task 2: isNaN und forEach — vier plus ein gleichwertiger Tausch</name>
<files>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</files>
<read_first>
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.
</read_first>
<action>
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.
</action>
<verify>
<automated>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"</automated>
<automated>pnpm --filter @tessera/web test 2>&amp;1 | tail -5</automated>
<automated>pnpm type-check 2>&amp;1 | tail -4</automated>
</verify>
<done>
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).
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: exec-Schleifen, LDAP-NUL-Maskierung und das Sprachumschalter-Cookie</name>
<files>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</files>
<read_first>
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.
</read_first>
<behavior>
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.
</behavior>
<action>
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 `<done>` schlaegt fehl.
Keine neue Abhaengigkeit, keine Versionsanhebung, keine Formatierung ueber
die genannten Dateien hinaus (D-05).
</action>
<verify>
<automated>pnpm --filter @tessera/api test 2>&amp;1 | tail -5</automated>
<automated>pnpm --filter @tessera/web test 2>&amp;1 | tail -5</automated>
<automated>pnpm type-check 2>&amp;1 | tail -4</automated>
<automated>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"</automated>
<automated>pnpm lint 2>&amp;1 | tail -4</automated>
</verify>
<done>
`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).
</done>
<reversibility rating="reversible">Mechanische Umschreibungen plus zwei Kommentare; die beiden neuen Spezifikationen halten das Verhalten fest, ein Zurueckrollen ist folgenlos.</reversibility>
</task>
</tasks>
<threat_model>
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; `<done>` 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. |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/260921-i8x-SUMMARY.md` when done.
Die Zusammenfassung enthaelt die Urteilstabelle aus `<verdicts>` mit dem
tatsaechlich Umgesetzten je Zeile, die Vorher-/Nachher-Zahlen aus Abschnitt
`<verification>` und — ausdruecklich — den Satz, dass sieben der zwoelf Befunde
keine Fehler waren.
</output>
+54
View File
@@ -1,6 +1,30 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildNextParam, sanitizeNextPath } from './safe-next'; 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). * safe-next.test — Ruecksprung nach Anmeldung (quick-260917-gyd).
* *
@@ -70,4 +94,34 @@ describe('sanitizeNextPath', () => {
expect(sanitizeNextPath('/login/')).toBe('/'); expect(sanitizeNextPath('/login/')).toBe('/');
expect(sanitizeNextPath('/loginhistory')).toBe('/loginhistory'); 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('/');
});
}); });
Binary file not shown.