Härtung des Lesepfads vor der Exposition: ReDoS, fehlender Subprozess-Timeout, YAML-Alias-Budget, Korpus-Cache #33
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:147gibt nutzergesteuerten Regex an PythonsBacktracking-Engine:
Reproduziert:
(\w+\s?)+$gegen einen 114-Zeichen-Satz aus echtem Korpusinhalt terminiert in8 Sekunden nicht. Ein deterministisch scheiterndes Muster (
^(\w+\s+)+$) braucht 0,2 ms — derUnterschied ist nicht der Haystack, sondern das Muster.
Amplifikation:
build_hitruft_containspro Treffer auf Titel und Summary. Ein trivialerRegex wie
\wmatcht jede Seite, also ~2 Suchen pro Seite, jede unbegrenzt.Fix: den
query.regex-Zweig in_containslöschen.rgentscheidet Regex ohnehin mit einerlinearen Engine (
build_argv), der Python-Zweig ist Doppelarbeit mit exponentiellemWorst Case. Zwei Zeilen plus ein Test.
2. Kein Timeout auf dem Subprozess
tools/chemenu/search/ripgrep.py:102:Kein
timeout=. Ein breiter Regex kostet inrggemessen 1,4 s statt 0,006 s imFixed-String-Fall; ein hängender Aufruf blockiert unbegrenzt und puffert vollständig in den Heap.
Fix:
timeout=setzen,subprocess.TimeoutExpiredin den vorhandenenRipgrepFailed-Pfad übersetzen.3.
yaml.safe_loadohne Alias-Budgettools/chemenu/frontmatter_io.py:37. Reproduziert: 322 Byte YAML mit verschachtelten Aliaseswerden 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 liefertSuchtext, 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_loadim Lesepfad; für den Quarantänepfad aus #32zusätzlich strikt statt still auf
{}zurückfallen (siehe Punkt 5).4.
CSafeLoaderliegt ungenutzt herumlibyamlist verfügbar. Gemessen über denselben Korpus:SafeLoader246 ms,CSafeLoader82 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 derGrößenordnung „wenige Tausend Seiten" ist das der Unterschied zwischen erträglich und nicht.
Fix:
CSafeLoaderbenutzen, wo verfügbar, mit Fallback aufSafeLoader. Achtung: dasAlias-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 gebrochenemFrontmatter verliert damit
confidence/kindund rutscht durch jedes--field-Prädikathindurch.
lintsieht 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 Seitenbei 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
_containsgibt keinen nutzergesteuerten Regex mehr anre; Test mit(\w+\s?)+$als Regressionsubprocess.runinripgrep.pyhat eintimeout=, Überschreitung wird alsRipgrepFailedgemeldetsafe_load; Test mit dem 322-Byte-Alias-Dokument als RegressionCSafeLoadergenutzt, mit Fallback; Verhalten unverändert (Round-Trip-Tests grün)--patch, Changelog-EintragTeil 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 Konfliktumsonst.
Umgesetzt in 2.2.4. Alle sechs Punkte, jeder mit einer Regression.
1. ReDoS. Der
query.regex-Zweig in_containsist gelöscht, nicht begrenzt.rghatdas 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
rgzählt. Ein überwiegend literales Muster(
longhorn) bekommt seinen Bonus weiterhin über den Substring-Pfad. Regression prüft beides:Laufzeit (
< 0,5 sbei(\w+\s?)+$) und Ergebnis, weil eine Zeitschranke allein auch miteiner bloß schnelleren Engine grün würde.
2. Timeout.
RIPGREP_TIMEOUT_SECONDS = 30,TimeoutExpired→RipgrepFailed. KeinPerformance-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 — kostetO(Text) und löst nie aus, wonach sie fragt.
*ist in jedem Alias-Knoten notwendig, seineAbwesenheit beweist Abwesenheit ohne Parse; das ist der Weg jeder echten Seite. Dazu 64 KiB
Größenlimit und ein Abfangen von
RecursionError(PyYAML komponiert rekursiv, tiefeVerschachtelung 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 --jsonträgt immer eineunreadable-Listeaus
{path, reason}, die Tabellenform schreibt dieselben Zeilen nach stderr. Nötig, weil so eineSeite weder
confidencenochkindhat 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 — keinModul-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:
wikitool searchend-to-end (best of 5)Die verbleibenden ~262 ms sind Modulimport und entfallen erst im residenten Prozess (#19).
Nebenertrag:
read_page()undfrontmatter_error()liefen getrennt durchsafe_load— sokonnte der permissive Weg zu
{}degradieren aus einem Grund, den der strikte Weg andersbeschrieb, 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-stringsals Default,---Terminator. Der Modul-Docstring führt sie jetzt als drei tragende Zusagen statt zwei — diedritte ist, dass nutzergesteuerte Muster ausschließlich
rgsieht.