From f85c91be01ff7dc0fabd5a501b0ca12c17971d94 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 13:26:25 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J --- .../260921-i8x-PLAN.md | 530 ++++++++++++++++++ apps/web/src/lib/safe-next.test.ts | 54 ++ apps/web/src/lib/safe-next.ts | Bin 2458 -> 2759 bytes 3 files changed, 584 insertions(+) create mode 100644 .planning/quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/260921-i8x-PLAN.md 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. + + + + +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. + 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 4daa68164a5b235203c5ed73f82c94e7ad2c20f8..6a2afbb9b4558e1d128d393c7b051c45695027d2 100644 GIT binary patch delta 437 zcmX|8%SuBr5Csb^TnRQKX~8Te2j=9=nUl}v@5%Sq!L|jO5FR}SbQ`3iLWbw_PCPIp*}?AUD9=G`9f_&c z8?|8&@j!VbsL5$qTLz5|hPuRTLRuTFl|u-edQ0dVLiS09mMiN#q4T@uRx3A;Po+|3 z1~n#;TdOexwnYVlyGeF^KNeV{tC2NwJBiH5E-|B$ZJc3JB4N9F}xr}PbSvD<*`Un1;2r(tbEs5r~7f*IBQNr S`hYB5-Fd3L`Oj5jdiV$XV3tn+ delta 149 zcmX>uI!k!N3Z{C6;{5W=RD~2E4lYS8O)aWQ%}mZn%~LSYH8xZz%}Y@*G&1MX*H_R; z%qdm~&C5*APf68vEly4>NG;Y;0P-_R6k;lL6v|UGa}