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
- 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).
- 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.
- Attacker submits markdown containing:
.. include:: ../../../etc/passwd
or, on platforms where attacker can guess the absolute path:
os.path.join('/var/www/docs', '/etc/passwd') returns /etc/passwd; os.path.normpath does not undo this.
- 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.
- 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]
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(becauseos.path.joindiscards prior components when the next is absolute). The only guards are: (a) reject self-include, (b) requireos.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.Why it's wrong: the
os.path.normpath+os.path.joinpattern 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/passwdis normalised to a path outside the base. The plugin should compareos.path.commonpath([os.path.realpath(base), os.path.realpath(dest)]) == os.path.realpath(base)(or usepathlib.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 theIncludeplugin enabled have no way to constrain what the renderer can read.Exploit Chain
Includeplugin enabled (a documented configuration; the include directive ships in the package and the only set-up isRSTDirective([Include()])in the plugin list).state.env['__file__']to the markdown's source path. This is the documented integration: rendering a.mdfile 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.os.path.join('/var/www/docs', '/etc/passwd')returns/etc/passwd;os.path.normpathdoes not undo this./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.<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.htmlfile embeds the content asblock_htmlwith no escaping. Withescape=Falsethe 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.htmlextension 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_rsaif readable,~/.bash_history). Combined withescape=False: arbitrary HTML injection by uploading a.htmlfile alongside the markdown and including it.Preconditions: application uses mistune with the
Includedirective 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:
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.commonpathafter normalisation. Reject any path whose real path is not a subpath of the base directory:This rejects
..traversal, absolute paths, and symlink-following escapes (becauserealpathresolves symlinks). Optional defense-in-depth (separate change): also drop the.html/.xhtml/.htmbranch entirely, or always escape included HTML even when the document'sescape=False. The HTML-inclusion-with-escape-False combination has no safe use case for an untrusted markdown corpus; an opt-in flag likeInclude(allow_html=True)would force callers to acknowledge the risk.Add a regression test: