OSV 1.4.0 · github-reviewed · 修改于 2026-07-21 05:24
发布时间
2026-07-21 05:24
GitHub 审查时间
2026-07-21 05:24
NVD 发布时间
2026-07-09 01:17
源文件
advisories/github-reviewed/2026/07/GHSA-8mpj-m6qm-5qr8/GHSA-8mpj-m6qm-5qr8.json
Type: Uncontrolled recursion via mutual include. The Include directive checks for direct self-reference (a.md cannot include a.md), but does not detect indirect cycles. Two markdown files that include each other (a.md → includes b.md → includes a.md) cause unbounded recursion until Python's stack limit fires RecursionError. The exception propagates out of the renderer and crashes the calling code.
File: src/mistune/directives/include.py, lines 33-37 (the self-include check is the only cycle-detection logic).
Root cause: the include logic only compares os.path.abspath(dest) == os.path.abspath(source_file). There is no per-render set of "files already included" that would catch transitive cycles. When a.md includes b.md, the recursive block.parse(new_state) call uses dest (b.md) as the new __file__, which then includes a.md (passing the self-check, because the immediate parent file is b.md, not a.md), which then includes b.md, and so on. Each recursion level adds Python frames; the default stack limit of 1000 frames trips after ~7-10 cycle iterations and Python raises RecursionError. Since the directive does not catch the exception, it propagates out of Markdown.parse() and surfaces in the calling code, crashing the request.
File: src/mistune/directives/include.py, lines 28-54.
relpath = self.parse_title(m)
dest = os.path.join(os.path.dirname(source_file), relpath)
dest = os.path.normpath(dest)
if os.path.abspath(dest) == os.path.abspath(source_file): # <-- only catches direct self-include
return {"type": "block_error", "raw": "Could not include self: " + relpath}
if not os.path.isfile(dest):
return {"type": "block_error", "raw": "Could not find file: " + relpath}
with open(dest, "rb") as f:
content = f.read().decode(encoding)
ext = os.path.splitext(relpath)[1]
if ext in {".md", ".markdown", ".mkd"}:
new_state = block.state_cls()
new_state.env["__file__"] = dest
new_state.process(content)
block.parse(new_state) # <-- recursive parse, no cycle tracking
return new_state.tokens
Why it's wrong: the cycle-detection check is one level deep. Multi-file cycles slip through trivially. Python's default recursion limit is 1000 frames, so a cycle of length 2 trips after a few hundred mutual includes; the exception is uncaught by the directive, propagating out of Markdown.__call__() and crashing whatever called it.
Include directive enabled. Application accepts user-supplied markdown files (CMS, wiki, multi-user documentation platform, note-taking app, CI/CD doc renderer).a.md: .. include:: b.mdb.md: .. include:: a.mda.md (or any markdown that references this pair). Include directive includes b.md, which includes a.md, which includes b.md, ... Each recursion adds Python frames.sys.setrecursionlimit(1000) and the per-include frame depth), Python raises RecursionError: maximum recursion depth exceeded.block.parse, through Markdown.__call__, and into the application's request handler. If the application doesn't catch it explicitly, the request errors out (HTTP 500 in web contexts, crash in CLI tools).Attacker capability: crash the rendering engine on demand by submitting any markdown that triggers the cycle. Repeated requests deny service. If the renderer is used in a hot path (per-page-view docs rendering, search-index regeneration, scheduled doc-export jobs), the cycle persists across the whole pipeline.
Preconditions: application uses mistune with the Include directive enabled and renders user-supplied markdown that can reference other user-uploaded files. Attacker needs write access to two .md files in the include search path (or a single file including a known-recurring pair).
Differential: PoC-verified against [email protected]:
import os, mistune
from mistune.directives import RSTDirective, Include
os.makedirs('/tmp/mistune-recur', exist_ok=True)
with open('/tmp/mistune-recur/a.md', 'w') as f:
f.write('A\n\n.. include:: b.md')
with open('/tmp/mistune-recur/b.md', 'w') as f:
f.write('B\n\n.. include:: a.md')
md = mistune.create_markdown(plugins=[RSTDirective([Include()])])
state = md.block.state_cls()
state.env['__file__'] = '/tmp/mistune-recur/a.md'
md.parse('.. include:: b.md', state=state)
# RecursionError: maximum recursion depth exceeded
The patched build (with the suggested fix below) returns a block_error token like the existing self-include check, instead of recursing forever.
Track included paths in state.env and reject any include that would re-enter a path already on the include stack:
--- a/src/mistune/directives/include.py
+++ b/src/mistune/directives/include.py
@@ -28,8 +28,18 @@ class Include(DirectivePlugin):
relpath = self.parse_title(m)
- dest = os.path.join(os.path.dirname(source_file), relpath)
- dest = os.path.normpath(dest)
+ base = os.path.realpath(os.path.dirname(source_file))
+ dest = os.path.realpath(os.path.join(base, relpath))
+
+ # Track include stack across recursive parses to detect cycles.
+ include_stack = state.env.setdefault("__include_stack__", [])
+ if dest in include_stack or dest == os.path.realpath(source_file):
+ return {
+ "type": "block_error",
+ "raw": "Could not include (cycle): " + relpath,
+ }
- if os.path.abspath(dest) == os.path.abspath(source_file):
- return {
- "type": "block_error",
- "raw": "Could not include self: " + relpath,
- }
@@ ... in the markdown-include branch ...
+ include_stack.append(dest)
+ try:
+ new_state = block.state_cls()
+ new_state.env["__file__"] = dest
+ new_state.env["__include_stack__"] = include_stack
+ new_state.process(content)
+ block.parse(new_state)
+ return new_state.tokens
+ finally:
+ include_stack.pop()
This catches cycles of any length (a → b → a, a → b → c → a, etc.). Pair this with the path-containment fix from the LFI advisory and the HTML-extension fix from the include-XSS advisory; together those three patches make the Include directive safe to enable on user-supplied markdown.
Add a regression test asserting that a 2-cycle and a 3-cycle both produce block_error rather than RecursionError.