Skip to content

directives/include: `.. include::` directive accepts unconstrained relative and absolute paths, allowing arbitrary file read by any markdown the renderer processes

High
lepture published GHSA-9jjx-3hq3-hx4j Jun 21, 2026

Package

pip mistune (pip)

Affected versions

<= 3.2.1

Patched versions

3.3.0

Description

Summary

Type: Path traversal in the .. include:: RST directive. The plugin reads any file readable by the rendering process and embeds the content in the output. There is no allowlist, no chroot, no .. rejection, no absolute-path rejection.
File: src/mistune/directives/include.py, lines 28-46.
Root cause: relpath = self.parse_title(m) takes the directive argument verbatim from the markdown source. dest = os.path.normpath(os.path.join(os.path.dirname(source_file), relpath)) resolves .. segments and normalises but does not constrain the result to the source file's directory or any other base. os.path.join(base, '/etc/passwd') returns /etc/passwd (because os.path.join discards prior components when the next is absolute). The only guards are: (a) reject self-include, (b) require os.path.isfile(dest). Both pass for /etc/passwd, ~/.ssh/id_rsa, the renderer process's .bash_history, the surrounding application's .env, etc. The file content is then either parsed as markdown (if .md/.markdown/.mkd), embedded as raw HTML (if .html/.xhtml/.htm), or wrapped in a <pre class="directive-include"> block (any other extension) and dumped into the rendered HTML output.

Affected Code

File: src/mistune/directives/include.py, lines 12-64.

class Include(DirectivePlugin):
    def parse(self, block, m, state):
        source_file = state.env.get("__file__")
        if not source_file:
            return {"type": "block_error", "raw": "Missing source file"}

        # ... encoding parse ...

        relpath = self.parse_title(m)                                       # <-- BUG: attacker-controlled string from markdown
        dest = os.path.join(os.path.dirname(source_file), relpath)          # <-- BUG: '/etc/passwd' as relpath bypasses base via os.path.join
        dest = os.path.normpath(dest)                                       # <-- BUG: normpath resolves '..' but does not enforce base containment

        if os.path.abspath(dest) == os.path.abspath(source_file):
            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"}:
            # ... render markdown content into output tokens ...
        elif ext in {".html", ".xhtml", ".htm"}:
            return {"type": "block_html", "raw": content}                   # <-- combined with escape=False, HTML inclusion is stored XSS
        # ... other ext: wrapped in <pre class="directive-include"> ...

Why it's wrong: the os.path.normpath + os.path.join pattern is a well-known traversal anti-pattern; it normalises but does not contain. os.path.join(base, '/etc/passwd') returns /etc/passwd (Python documents this behaviour explicitly: a subsequent absolute path discards prior components). Even for relative inputs, ../../../etc/passwd is normalised to a path outside the base. The plugin should compare os.path.commonpath([os.path.realpath(base), os.path.realpath(dest)]) == os.path.realpath(base) (or use pathlib.Path.resolve().is_relative_to(base.resolve()) on Python 3.9+) before opening the file. None of those checks exist. Callers that legitimately want to render markdown trees with the Include plugin enabled have no way to constrain what the renderer can read.

Exploit Chain

  1. Application uses mistune to render user-supplied markdown and has the directives + Include plugin enabled (a documented configuration; the include directive ships in the package and the only set-up is RSTDirective([Include()]) in the plugin list).
  2. Application sets state.env['__file__'] to the markdown's source path. This is the documented integration: rendering a .md file from disk requires __file__ to be set so relative includes resolve against the file's directory. Many users set this for any markdown that lives on the filesystem.
  3. Attacker submits markdown containing:
    .. include:: ../../../etc/passwd
    
    or, on platforms where attacker can guess the absolute path:
    .. include:: /etc/passwd
    
    os.path.join('/var/www/docs', '/etc/passwd') returns /etc/passwd; os.path.normpath does not undo this.
  4. Plugin opens the file (any file readable by the renderer process — /etc/passwd, the application's .env, ~/.aws/credentials, the running web server's session DB, log files containing tokens, etc.), reads it, and embeds the content in the output token stream.
  5. Final state: the rendered HTML contains <pre class="directive-include">\n<contents of /etc/passwd>\n</pre>. The attacker can read it back from whatever surface the application uses to display the rendered HTML (return body of an HTTP request, generated static page, email body, chat message, Slack post, etc.).

A second attack mode applies when the application also uses escape=False (a common opt-in for trusting one's own markdown corpus): including an attacker-supplied .html file embeds the content as block_html with no escaping. With escape=False the result is stored XSS.

Security Impact

Severity: sec-high. Information disclosure of any file readable by the rendering process. Combined with escape=False, also a stored XSS primitive via the .html extension branch.
Attacker capability: read any file the rendering process can open(). On a typical web stack that includes: /etc/passwd, /proc/self/environ (process environment, often containing API keys / DB credentials), the application's own .env / config.py / settings.local.py (database creds, session secrets, JWT signing keys), log files (request URLs that may contain bearer tokens, error tracebacks with PII), home-directory files of the user the renderer runs as (~/.aws/credentials, ~/.ssh/id_rsa if readable, ~/.bash_history). Combined with escape=False: arbitrary HTML injection by uploading a .html file alongside the markdown and including it.
Preconditions: application uses mistune with the Include directive enabled (RSTDirective([Include()]) in plugins). state.env['__file__'] must be set, which is the documented setup for rendering markdown files from disk and is done by every framework integration that supports the directive (Sphinx-style docs renderers, MkDocs-mistune integrations, JupyterLite previewers, any tool that says "renders markdown including .. include:: directives").
Differential: PoC-verified against mistune@3.2.1, default config:

import os, mistune
from mistune.directives import RSTDirective, Include

os.makedirs('/tmp/mistune-include-test', exist_ok=True)
md = mistune.create_markdown(plugins=[RSTDirective([Include()])])

state = md.block.state_cls()
state.env['__file__'] = '/tmp/mistune-include-test/index.md'

# Relative traversal
print(md.parse('.. include:: ../../../etc/passwd', state=state)[0][:300])
# => <pre class="directive-include">
#    root:x:0:0:root:/root:/bin/bash
#    daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
#    ...

# Absolute path bypass
state2 = md.block.state_cls()
state2.env['__file__'] = '/tmp/mistune-include-test/index.md'
print(md.parse('.. include:: /etc/passwd', state=state2)[0][:300])
# => same /etc/passwd contents

The patched build (with the suggested fix below) returns {"type": "block_error", "raw": "Could not find file: ..."} (or analogous) for any path that resolves outside the base directory. The vulnerable build returns the file contents.

Suggested Fix

Containment check via os.path.realpath + os.path.commonpath after normalisation. Reject any path whose real path is not a subpath of the base directory:

--- a/src/mistune/directives/include.py
+++ b/src/mistune/directives/include.py
@@ -27,12 +27,21 @@ class Include(DirectivePlugin):
         else:
             attrs = {}

         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))
+
+        # Reject absolute paths and any traversal that escapes `base`. This
+        # mirrors the constraint a templating engine like Jinja2 enforces on
+        # its filesystem loader: an "include" only sees siblings of the
+        # source file, never the rest of the disk.
+        if os.path.isabs(relpath) or os.path.commonpath([base, dest]) != base:
+            return {
+                "type": "block_error",
+                "raw": "Could not include outside source dir: " + relpath,
+            }

         if os.path.abspath(dest) == os.path.abspath(source_file):
             return {
                 "type": "block_error",

This rejects .. traversal, absolute paths, and symlink-following escapes (because realpath resolves symlinks). Optional defense-in-depth (separate change): also drop the .html/.xhtml/.htm branch entirely, or always escape included HTML even when the document's escape=False. The HTML-inclusion-with-escape-False combination has no safe use case for an untrusted markdown corpus; an opt-in flag like Include(allow_html=True) would force callers to acknowledge the risk.

Add a regression test:

def test_include_rejects_traversal():
    md = mistune.create_markdown(plugins=[RSTDirective([Include()])])
    state = md.block.state_cls()
    state.env['__file__'] = '/tmp/mistune-test/index.md'
    out = md.parse('.. include:: ../../../etc/passwd', state=state)
    assert 'root:' not in out[0]
    out = md.parse('.. include:: /etc/passwd', state=state)
    assert 'root:' not in out[0]

Severity

High

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
None
Scope
Changed
Confidentiality
High
Integrity
None
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:N/S:C/C:H/I:N/A:N

CVE ID

No known CVE

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

External Control of File Name or Path

The product allows user input to control or influence paths or file names that are used in filesystem operations. Learn more on MITRE.

Credits