Härtung des Lesepfads vor der Exposition: ReDoS, fehlender Subprozess-Timeout, YAML-Alias-Budget, Korpus-Cache #33

Closed
opened 2026-09-01 21:10:41 +00:00 by torben · 2 comments
Owner

Aus der Plattform-Debatte vom 2026-09-01. Alle Befunde sind verifiziert, nicht vermutet —
jeder wurde in dieser Sitzung reproduziert. Sie gehören zusammen, weil sie denselben Codepfad
betreffen und weil sie zusammen Voraussetzung dafür sind, dass #19 überhaupt exponiert werden
darf.

1. ReDoS über --regex (der teuerste Befund)

tools/chemenu/search/ripgrep.py:147 gibt nutzergesteuerten Regex an Pythons
Backtracking-Engine:

return re.search(query.text, haystack, re.IGNORECASE) is not None

Reproduziert: (\w+\s?)+$ gegen einen 114-Zeichen-Satz aus echtem Korpusinhalt terminiert in
8 Sekunden nicht. Ein deterministisch scheiterndes Muster (^(\w+\s+)+$) braucht 0,2 ms — der
Unterschied ist nicht der Haystack, sondern das Muster.

Amplifikation: build_hit ruft _contains pro Treffer auf Titel und Summary. Ein trivialer
Regex wie \w matcht jede Seite, also ~2 Suchen pro Seite, jede unbegrenzt.

Fix: den query.regex-Zweig in _contains löschen. rg entscheidet Regex ohnehin mit einer
linearen Engine (build_argv), der Python-Zweig ist Doppelarbeit mit exponentiellem
Worst Case. Zwei Zeilen plus ein Test.

2. Kein Timeout auf dem Subprozess

tools/chemenu/search/ripgrep.py:102:

proc = subprocess.run(argv, capture_output=True, text=True, check=False)

Kein timeout=. Ein breiter Regex kostet in rg gemessen 1,4 s statt 0,006 s im
Fixed-String-Fall; ein hängender Aufruf blockiert unbegrenzt und puffert vollständig in den Heap.

Fix: timeout= setzen, subprocess.TimeoutExpired in den vorhandenen
RipgrepFailed-Pfad übersetzen.

3. yaml.safe_load ohne Alias-Budget

tools/chemenu/frontmatter_io.py:37. Reproduziert: 322 Byte YAML mit verschachtelten Aliases
werden zu 797.161 logischen Knoten beim Traversal, Wachstum 3ⁿ bei konstanter Parse-Zeit. Ein
Lauf mit Tiefe 15/18 lief in ein 60-Sekunden-Timeout.

Heute nicht erreichbar: kb/ committet der Operator selbst, und der Anfragesteller liefert
Suchtext, kein YAML. Der Befund ist eine Parser-Eigenschaft, kein offener Pfad — er wird einer,
sobald fremdes YAML den Parser erreicht (#32).

Fix: Größen- und Tiefenlimit vor safe_load im Lesepfad; für den Quarantänepfad aus #32
zusätzlich strikt statt still auf {} zurückfallen (siehe Punkt 5).

4. CSafeLoader liegt ungenutzt herum

libyaml ist verfügbar. Gemessen über denselben Korpus: SafeLoader 246 ms, CSafeLoader
82 ms
— Faktor 3, für eine Änderung an einer Stelle.

Das ist kein Mikro-Tuning: der Korpus-Parse ist mit 274 ms von 593 ms der größte Einzelposten
eines search-Aufrufs und skaliert linear mit der Korpusgröße. Bei einem Korpus in der
Größenordnung „wenige Tausend Seiten" ist das der Unterschied zwischen erträglich und nicht.

Fix: CSafeLoader benutzen, wo verfügbar, mit Fallback auf SafeLoader. Achtung: das
Alias-Problem aus Punkt 3 bleibt bestehen, die Limits sind unabhängig davon nötig.

5. Stiller Frontmatter-Verlust

tools/chemenu/frontmatter_io.py:37-41: kaputtes YAML wird zu {}. Eine Seite mit gebrochenem
Frontmatter verliert damit confidence/kind und rutscht durch jedes --field-Prädikat
hindurch. lint sieht es, die Suche nicht.

Fix: entscheiden, ob der Lesepfad das melden statt schlucken soll. Mindestens: der
Quarantäne-/Ingest-Pfad muss strikt sein.

6. Korpus-Reparse pro Aufruf

tools/chemenu/commands/search.py:199load_pages_by_path() liest und parst alle Seiten
bei jedem Aufruf: gemessen 274 ms für 176 Seiten. Ein Ein-Byte-Request kostet O(Korpus).

Fix: Cache, invalidiert am git-Commit-SHA des Checkouts. Derselbe SHA ist der
Antwort-Stempel aus #19 — ein Schlüssel für beides.

Was das zusammen bewirkt

Von 593 ms bleiben nach Punkt 4 und 6 plus dem residenten Prozess aus #19 (Importkosten einmal
statt pro Aufruf: −262 ms) grob 100 ms. Ohne Sprachwechsel.

Abgrenzung

Authentifizierung ist nicht Teil dieses Issues. Sie wird laut Betreiberentscheidung vom
2026-09-01 als Traefik-Middleware abgebildet; nicht sauber authentifizierte Zugriffe erreichen
den Python-Prozess nicht. Das entschärft Punkt 1 und 2 gegenüber anonymen Angreifern, hebt sie
aber nicht auf: ein authentifizierter Konsument kann dieselben Anfragen stellen, und ein
Ressourcenlimit gegen versehentliche Selbstbeschädigung bleibt richtig.

Akzeptanzkriterien

  • _contains gibt keinen nutzergesteuerten Regex mehr an re; Test mit (\w+\s?)+$ als Regression
  • subprocess.run in ripgrep.py hat ein timeout=, Überschreitung wird als RipgrepFailed gemeldet
  • Größen-/Tiefenlimit vor safe_load; Test mit dem 322-Byte-Alias-Dokument als Regression
  • CSafeLoader genutzt, mit Fallback; Verhalten unverändert (Round-Trip-Tests grün)
  • Entscheidung zu Punkt 5 getroffen und dokumentiert
  • Korpus-Cache am Commit-SHA; Messung vorher/nachher im Issue vermerkt
  • Version-Bump --patch, Changelog-Eintrag
Aus der Plattform-Debatte vom 2026-09-01. Alle Befunde sind **verifiziert**, nicht vermutet — jeder wurde in dieser Sitzung reproduziert. Sie gehören zusammen, weil sie denselben Codepfad betreffen und weil sie zusammen Voraussetzung dafür sind, dass #19 überhaupt exponiert werden darf. ## 1. ReDoS über `--regex` (der teuerste Befund) `tools/chemenu/search/ripgrep.py:147` gibt nutzergesteuerten Regex an Pythons Backtracking-Engine: ```python return re.search(query.text, haystack, re.IGNORECASE) is not None ``` Reproduziert: `(\w+\s?)+$` gegen einen 114-Zeichen-Satz aus echtem Korpusinhalt terminiert in 8 Sekunden nicht. Ein deterministisch scheiterndes Muster (`^(\w+\s+)+$`) braucht 0,2 ms — der Unterschied ist nicht der Haystack, sondern das Muster. Amplifikation: `build_hit` ruft `_contains` pro Treffer auf Titel **und** Summary. Ein trivialer Regex wie `\w` matcht jede Seite, also ~2 Suchen pro Seite, jede unbegrenzt. **Fix: den `query.regex`-Zweig in `_contains` löschen.** `rg` entscheidet Regex ohnehin mit einer linearen Engine (`build_argv`), der Python-Zweig ist Doppelarbeit mit exponentiellem Worst Case. Zwei Zeilen plus ein Test. ## 2. Kein Timeout auf dem Subprozess `tools/chemenu/search/ripgrep.py:102`: ```python proc = subprocess.run(argv, capture_output=True, text=True, check=False) ``` Kein `timeout=`. Ein breiter Regex kostet in `rg` gemessen 1,4 s statt 0,006 s im Fixed-String-Fall; ein hängender Aufruf blockiert unbegrenzt und puffert vollständig in den Heap. **Fix:** `timeout=` setzen, `subprocess.TimeoutExpired` in den vorhandenen `RipgrepFailed`-Pfad übersetzen. ## 3. `yaml.safe_load` ohne Alias-Budget `tools/chemenu/frontmatter_io.py:37`. Reproduziert: 322 Byte YAML mit verschachtelten Aliases werden zu 797.161 logischen Knoten beim Traversal, Wachstum 3ⁿ bei konstanter Parse-Zeit. Ein Lauf mit Tiefe 15/18 lief in ein 60-Sekunden-Timeout. Heute **nicht erreichbar**: `kb/` committet der Operator selbst, und der Anfragesteller liefert Suchtext, kein YAML. Der Befund ist eine Parser-Eigenschaft, kein offener Pfad — er wird einer, sobald fremdes YAML den Parser erreicht (#32). **Fix:** Größen- und Tiefenlimit vor `safe_load` im Lesepfad; für den Quarantänepfad aus #32 zusätzlich strikt statt still auf `{}` zurückfallen (siehe Punkt 5). ## 4. `CSafeLoader` liegt ungenutzt herum `libyaml` ist verfügbar. Gemessen über denselben Korpus: `SafeLoader` 246 ms, **`CSafeLoader` 82 ms** — Faktor 3, für eine Änderung an einer Stelle. Das ist kein Mikro-Tuning: der Korpus-Parse ist mit 274 ms von 593 ms der größte Einzelposten eines `search`-Aufrufs und skaliert linear mit der Korpusgröße. Bei einem Korpus in der Größenordnung „wenige Tausend Seiten" ist das der Unterschied zwischen erträglich und nicht. **Fix:** `CSafeLoader` benutzen, wo verfügbar, mit Fallback auf `SafeLoader`. Achtung: das Alias-Problem aus Punkt 3 bleibt bestehen, die Limits sind unabhängig davon nötig. ## 5. Stiller Frontmatter-Verlust `tools/chemenu/frontmatter_io.py:37-41`: kaputtes YAML wird zu `{}`. Eine Seite mit gebrochenem Frontmatter verliert damit `confidence`/`kind` und rutscht durch jedes `--field`-Prädikat hindurch. `lint` sieht es, die Suche nicht. **Fix:** entscheiden, ob der Lesepfad das melden statt schlucken soll. Mindestens: der Quarantäne-/Ingest-Pfad muss strikt sein. ## 6. Korpus-Reparse pro Aufruf `tools/chemenu/commands/search.py:199` → `load_pages_by_path()` liest und parst **alle** Seiten bei jedem Aufruf: gemessen 274 ms für 176 Seiten. Ein Ein-Byte-Request kostet O(Korpus). **Fix:** Cache, invalidiert am git-Commit-SHA des Checkouts. Derselbe SHA ist der Antwort-Stempel aus #19 — ein Schlüssel für beides. ## Was das zusammen bewirkt Von 593 ms bleiben nach Punkt 4 und 6 plus dem residenten Prozess aus #19 (Importkosten einmal statt pro Aufruf: −262 ms) grob **100 ms**. Ohne Sprachwechsel. ## Abgrenzung Authentifizierung ist **nicht** Teil dieses Issues. Sie wird laut Betreiberentscheidung vom 2026-09-01 als Traefik-Middleware abgebildet; nicht sauber authentifizierte Zugriffe erreichen den Python-Prozess nicht. Das entschärft Punkt 1 und 2 gegenüber anonymen Angreifern, hebt sie aber nicht auf: ein authentifizierter Konsument kann dieselben Anfragen stellen, und ein Ressourcenlimit gegen versehentliche Selbstbeschädigung bleibt richtig. ## Akzeptanzkriterien - [ ] `_contains` gibt keinen nutzergesteuerten Regex mehr an `re`; Test mit `(\w+\s?)+$` als Regression - [ ] `subprocess.run` in `ripgrep.py` hat ein `timeout=`, Überschreitung wird als `RipgrepFailed` gemeldet - [ ] Größen-/Tiefenlimit vor `safe_load`; Test mit dem 322-Byte-Alias-Dokument als Regression - [ ] `CSafeLoader` genutzt, mit Fallback; Verhalten unverändert (Round-Trip-Tests grün) - [ ] Entscheidung zu Punkt 5 getroffen und dokumentiert - [ ] Korpus-Cache am Commit-SHA; Messung vorher/nachher im Issue vermerkt - [ ] Version-Bump `--patch`, Changelog-Eintrag
torben added the prio/plannedsize/S labels 2026-09-01 21:10:41 +00:00
Author
Owner

Teil der Sequenz in #36 (Master: Weg zum MCP-Leseserver) — dort Schritt 1 von 3.

Vor #31, weil beide dieselben Dateien anfassen: dieses Issue oberflächlich
(search/ripgrep.py, frontmatter_io.py), #31 strukturell. Andersherum entsteht ein Konflikt
umsonst.

Teil der Sequenz in **#36** (Master: Weg zum MCP-Leseserver) — dort Schritt 1 von 3. Vor #31, weil beide dieselben Dateien anfassen: dieses Issue oberflächlich (`search/ripgrep.py`, `frontmatter_io.py`), #31 strukturell. Andersherum entsteht ein Konflikt umsonst.
Author
Owner

Umgesetzt in 2.2.4. Alle sechs Punkte, jeder mit einer Regression.

1. ReDoS. Der query.regex-Zweig in _contains ist gelöscht, nicht begrenzt. rg hat
das Muster mit einer linearen Engine längst angewendet, wenn die Funktion überhaupt läuft.
Verloren geht nur der zusätzliche Titel-/Summary-Bonus für nicht-literale Muster — und Summary
wie H1 sind selbst Zeilen in der Datei, die rg zählt. Ein überwiegend literales Muster
(longhorn) bekommt seinen Bonus weiterhin über den Substring-Pfad. Regression prüft beides:
Laufzeit (< 0,5 s bei (\w+\s?)+$) und Ergebnis, weil eine Zeitschranke allein auch mit
einer bloß schnelleren Engine grün würde.

2. Timeout. RIPGREP_TIMEOUT_SECONDS = 30, TimeoutExpiredRipgrepFailed. Kein
Performance-Budget, ein Hänger-Abbruch.

3. YAML-Aliases: verweigert, nicht budgetiert. Frontmatter hat keine legitime Verwendung
dafür. Die Prüfung läuft auf dem Event-Strom (yaml.parse), der nichts auflöst — kostet
O(Text) und löst nie aus, wonach sie fragt. * ist in jedem Alias-Knoten notwendig, seine
Abwesenheit beweist Abwesenheit ohne Parse; das ist der Weg jeder echten Seite. Dazu 64 KiB
Größenlimit und ein Abfangen von RecursionError (PyYAML komponiert rekursiv, tiefe
Verschachtelung ist kein YAMLError).

Zur Zahl im Issue-Text: mein Regressionsdokument ist etwas kleiner geraten, 267 Byte → 672.603
Knoten beim Traversal, Parse in 0,2 ms
, Wachstum 9ⁿ. Dieselbe Eigenschaft, eigene Messung.

4. CSafeLoader. Gemessen über diesen Korpus (176 Seiten, best of 5): 265 ms → 54 ms.

5. Entscheidung getroffen: der Lesepfad meldet. Kaputtes YAML wird weiterhin zu {}
Massenoperationen dürfen an einer Seite nicht scheitern —, aber der Grund wird mitgeführt
(Page.frontmatter_error) und ausgegeben: search --json trägt immer eine unreadable-Liste
aus {path, reason}, die Tabellenform schreibt dieselben Zeilen nach stderr. Nötig, weil so eine
Seite weder confidence noch kind hat und damit aus jedem positiven --field-Prädikat fällt —
ausgerechnet aus der Low-Confidence-Suche, die Seiten in genau diesem Zustand finden soll — und
dabei aussieht wie eine Seite, die nicht gematcht hat. Ein leerer Frontmatter-Block gilt nicht als
Befund; eine Seite ganz ohne Block schon. Für den Quarantänepfad aus #32 steht
read_page_strict() bereit: unlesbar muss dort das Dokument stoppen, nicht es leeren.

6. Korpus-Cache in chemenu/corpus_cache.py, als Objekt, das ein Aufrufer hält — kein
Modul-Dict, das sich hinter allen einschaltet. Die CLI hält keins (ein Aufruf pro Prozess, nichts
wiederzuverwenden), der residente Prozess aus #19 hält eins. Entscheidend ist nicht Geschwindigkeit,
sondern dass nichts veraltet: ein schmutziger Arbeitsbaum wird nie gecacht, sonst bekäme eine
Sitzung, die eine Seite schreibt und danach sucht, die Analyse von vor dem Schreiben — bei
unverändertem SHA. Kann git nicht antworten, gilt der Baum als schmutzig.

Messung vorher/nachher, wie gefordert:

vorher nachher
Korpus-Parse (176 Seiten, best of 5) 265 ms 54 ms
wikitool search end-to-end (best of 5) 593 ms 347 ms

Die verbleibenden ~262 ms sind Modulimport und entfallen erst im residenten Prozess (#19).

Nebenertrag: read_page() und frontmatter_error() liefen getrennt durch safe_load — so
konnte der permissive Weg zu {} degradieren aus einem Grund, den der strikte Weg anders
beschrieb, und jeder Aufrufer, der beide Antworten wollte, las die Datei zweimal. Beide gehen
jetzt durch einen Parser; ein Test hält sie gegeneinander.

Erhalten geblieben ist Befund 10: kein shell=True, --fixed-strings als Default,
---Terminator. Der Modul-Docstring führt sie jetzt als drei tragende Zusagen statt zwei — die
dritte ist, dass nutzergesteuerte Muster ausschließlich rg sieht.

Umgesetzt in **2.2.4**. Alle sechs Punkte, jeder mit einer Regression. **1. ReDoS.** Der `query.regex`-Zweig in `_contains` ist **gelöscht**, nicht begrenzt. `rg` hat das Muster mit einer linearen Engine längst angewendet, wenn die Funktion überhaupt läuft. Verloren geht nur der zusätzliche Titel-/Summary-Bonus für nicht-literale Muster — und Summary wie H1 sind selbst Zeilen in der Datei, die `rg` zählt. Ein überwiegend literales Muster (`longhorn`) bekommt seinen Bonus weiterhin über den Substring-Pfad. Regression prüft beides: Laufzeit (`< 0,5 s` bei `(\w+\s?)+$`) **und** Ergebnis, weil eine Zeitschranke allein auch mit einer bloß schnelleren Engine grün würde. **2. Timeout.** `RIPGREP_TIMEOUT_SECONDS = 30`, `TimeoutExpired` → `RipgrepFailed`. Kein Performance-Budget, ein Hänger-Abbruch. **3. YAML-Aliases: verweigert, nicht budgetiert.** Frontmatter hat keine legitime Verwendung dafür. Die Prüfung läuft auf dem *Event*-Strom (`yaml.parse`), der nichts auflöst — kostet O(Text) und löst nie aus, wonach sie fragt. `*` ist in jedem Alias-Knoten notwendig, seine Abwesenheit beweist Abwesenheit ohne Parse; das ist der Weg jeder echten Seite. Dazu 64 KiB Größenlimit und ein Abfangen von `RecursionError` (PyYAML komponiert rekursiv, tiefe Verschachtelung ist kein `YAMLError`). Zur Zahl im Issue-Text: mein Regressionsdokument ist etwas kleiner geraten, **267 Byte → 672.603 Knoten beim Traversal, Parse in 0,2 ms**, Wachstum 9ⁿ. Dieselbe Eigenschaft, eigene Messung. **4. `CSafeLoader`.** Gemessen über diesen Korpus (176 Seiten, best of 5): **265 ms → 54 ms**. **5. Entscheidung getroffen: der Lesepfad meldet.** Kaputtes YAML wird weiterhin zu `{}` — Massenoperationen dürfen an einer Seite nicht scheitern —, aber der Grund wird mitgeführt (`Page.frontmatter_error`) und ausgegeben: `search --json` trägt immer eine `unreadable`-Liste aus `{path, reason}`, die Tabellenform schreibt dieselben Zeilen nach stderr. Nötig, weil so eine Seite weder `confidence` noch `kind` hat und damit aus jedem positiven `--field`-Prädikat fällt — ausgerechnet aus der Low-Confidence-Suche, die Seiten in genau diesem Zustand finden soll — und dabei aussieht wie eine Seite, die nicht gematcht hat. Ein leerer Frontmatter-Block gilt nicht als Befund; eine Seite ganz ohne Block schon. Für den Quarantänepfad aus #32 steht `read_page_strict()` bereit: unlesbar muss dort das Dokument stoppen, nicht es leeren. **6. Korpus-Cache** in `chemenu/corpus_cache.py`, als Objekt, das ein Aufrufer *hält* — kein Modul-Dict, das sich hinter allen einschaltet. Die CLI hält keins (ein Aufruf pro Prozess, nichts wiederzuverwenden), der residente Prozess aus #19 hält eins. Entscheidend ist nicht Geschwindigkeit, sondern dass nichts veraltet: **ein schmutziger Arbeitsbaum wird nie gecacht**, sonst bekäme eine Sitzung, die eine Seite schreibt und danach sucht, die Analyse von vor dem Schreiben — bei unverändertem SHA. Kann git nicht antworten, gilt der Baum als schmutzig. **Messung vorher/nachher**, wie gefordert: | | vorher | nachher | |---|---|---| | Korpus-Parse (176 Seiten, best of 5) | 265 ms | **54 ms** | | `wikitool search` end-to-end (best of 5) | 593 ms | **347 ms** | Die verbleibenden ~262 ms sind Modulimport und entfallen erst im residenten Prozess (#19). **Nebenertrag:** `read_page()` und `frontmatter_error()` liefen getrennt durch `safe_load` — so konnte der permissive Weg zu `{}` degradieren aus einem Grund, den der strikte Weg anders beschrieb, und jeder Aufrufer, der beide Antworten wollte, las die Datei zweimal. Beide gehen jetzt durch einen Parser; ein Test hält sie gegeneinander. **Erhalten geblieben** ist Befund 10: kein `shell=True`, `--fixed-strings` als Default, `--`-Terminator. Der Modul-Docstring führt sie jetzt als drei tragende Zusagen statt zwei — die dritte ist, dass nutzergesteuerte Muster ausschließlich `rg` sieht.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: torben/chemenu#33