CVE-2026-59930: Mistune toc / TableOfContents directive: heading IDs use predictable `toc_N` numbering with no slugification, allowing collision with attacker-controlled `id="toc_N"` content
Summary
Type: Predictable identifier generation. The toc plugin and TableOfContents directive both default to generating heading IDs of the form toc1, toc2, toc3, ... with no input-derived component. An attacker who can place a heading anywhere in the document can predict which tocN ID it will receive, and can inject HTML elsewhere (in a non-heading context) that uses the same id="tocN" to either (a) shadow the legitimate heading anchor, breaking same-page navigation, or (b) collide with CSS or JavaScript that targets #tocN selectors, redirecting click handlers and styling to attacker-chosen content. File: src/mistune/toc.py line 36-37 (headingid = lambda token, index: "toc" + str(index + 1)); src/mistune/directives/toc.py line 33 (same default). Root cause: the default headingid callback ignores the heading's text content and uses only the headings's order in the document. Two documents rendered together (or one document with attacker-influenced headings spliced into trusted content) produce overlapping tocN IDs. Because id attribute uniqueness is required by HTML, browsers behaviour on duplicate IDs is undefined; document.getElementById('toc1') returns the first match, getElementsByTagName + querySelector semantics differ across paths, and CSS rules targeting #toc1 apply to whichever element matches first in tree order.
Affected Code
File: src/mistune/toc.py, lines 33-39.
python def addtochook(md, minlevel=1, maxlevel=3, headingid=None): if headingid is None: def headingid(token, index): return "toc" + str(index + 1) # <-- BUG: index-only ID, no slug derived from heading text
File: src/mistune/directives/toc.py, lines 32-33.
python class TableOfContents(DirectivePlugin): def init(self, minlevel=1, maxlevel=3): # ...
def generateheadingid(self, token, index): return "toc" + str(index + 1) # <-- BUG: same predictable scheme
Why it's wrong: the standard markdown-engine convention (used by GitHub-flavoured Markdown, Sphinx, MkDocs, pandoc, every modern markdown renderer in production) is to slugify the heading TEXT for the ID — <h1 id="introduction">Introduction</h1> — with a numeric suffix appended only when slug collisions occur. mistune's default punts the slugification entirely and produces purely positional IDs that an attacker can predict in O(1).
The downstream impacts: - Same-page links with click go to whichever element with id="toc1" appears first in tree order. If the attacker can land any HTML element with id="toc1" before the real heading (via inlinehtml with escape=False, via the include-directive HTML branch, via attacker-supplied content earlier in the document), navigation is hijacked. - CSS rules targeting #toc1 apply to the wrong element. - JavaScript bound to document.getElementById('toc1') operates on the wrong element. - The TOC's own <a href="#toc1"> link in the rendered TOC list points to whichever element wins the duplicate-ID race.
Exploit Chain
1. Application uses mistune with addtochook(md) or TableOfContents directive enabled (the documented setup for sites with TOC support). 2. Application renders an attacker-supplied document, or splices attacker content into a trusted document. With escape=False (or via the include-directive .html branch covered by my prior advisory), the attacker can place <a id="toc1">...</a> anywhere in the document. 3. mistune assigns id="toc1" to the first heading. Now there are two elements with id="toc1" in the page. 4. The rendered TOC contains <a href="#toc1">First heading</a>. Clicking it navigates to whichever element with id="toc1" appears first in tree order. If the attacker placed their <a id="toc1"> BEFORE the heading, navigation is hijacked. 5. Same-page CSS / JS / aria-described references to #toc1 similarly redirect.
Security Impact
Severity: sec-low. Not a direct XSS or RCE; the issue is identifier confusion that enables UI-redirection / navigation-hijack attacks. The realistic attacker capability is "make an internal anchor link go to attacker content instead of the real heading", or "make a CSS selector apply to attacker content", or "break aria/screen-reader associations". Attacker capability: with the ability to plant any HTML element with id="tocN" in the document, hijack <a href="#tocN"> navigation and any CSS/JS targeting that ID. With escape=False, this is straightforward. With escape=True, the attacker needs another vector to land a raw id attribute (one of the include-directive branches, a sibling tooling pipeline that lets HTML through, etc.). Preconditions: application uses TOC + the default headingid callback. If the application provides its own headingid (e.g., one based on slugified heading text, with collision suffixes), this finding does not apply. Differential: PoC-verified against mistune@3.2.1:
python import mistune from mistune.directives import RSTDirective, TableOfContents md = mistune.createmarkdown(plugins=[RSTDirective([TableOfContents()])])
print(md(''' .. toc::
Heading 1
Heading 2 '''))
Output (note: id="toc1" / id="toc2", purely positional): <details class="toc" open> <summary>Table of Contents</summary> <ul> <li><a href="#toc1">Heading 1</a></li> <li><a href="#toc2">Heading 2</a></li> </ul> </details> <h1 id="toc1">Heading 1</h1> <h1 id="toc2">Heading 2</h1>
The patched build (with the suggested fix below) produces text-derived slugs like id="heading-1" and id="heading-2", which are tied to content rather than position.
Suggested Fix
Default to slugifying the heading text:
diff --- a/src/mistune/toc.py +++ b/src/mistune/toc.py @@ -33,9 +33,18 @@ def addtochook(md, minlevel=1, maxlevel=3, headingid=None): if headingid is None: + import re + slugre = re.compile(r"[^a-z0-9]+") + seen = {} def headingid(token, index): - return "toc" + str(index + 1) + text = striptags(md.renderer(md.inline(token["text"], {}), BlockState())) + slug = slugre.sub("-", text.lower()).strip("-") or "section" + n = seen.get(slug, 0) + seen[slug] = n + 1 + return slug if n == 0 else f"{slug}-{n}"
Same change applies to src/mistune/directives/toc.py:33. Existing applications that have hardcoded #tocN anchors will break; document the migration in the changelog and consider providing an opt-out flag for the legacy behaviour. Add a regression test that asserts heading IDs are slug-derived, not position-derived, and that collisions get a -N suffix.
Other sources
Mistune is a Python Markdown parser with renderers and plugins. Prior to 3.3.0, the toc plugin and TableOfContents directive generate heading IDs as predictable tocN values without slugifying the heading text, allowing attacker-controlled id="tocN" content to collide with generated anchors and redirect same-page navigation, CSS selectors, or JavaScript handlers. This issue is fixed in version 3.3.0.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/mistuneto a version that resolves this vulnerability.Fixed in 3.3.0 - Upgrade
Upgrade
mistuneto a version that resolves this vulnerability.Fixed in 3.3.0
Event History
Frequently Asked Questions
What is the severity of CVE-2026-59930?
CVE-2026-59930 has a severity rating of medium with a score of 4.3.
How do I fix CVE-2026-59930?
To fix CVE-2026-59930, upgrade to Mistune version 3.3.0 or later.
What is the risk of CVE-2026-59930?
CVE-2026-59930 poses a risk of 22, indicating a moderate level of threat.
Which software is affected by CVE-2026-59930?
CVE-2026-59930 affects the Mistune Markdown parser, specifically versions prior to 3.3.0.
What type of attack does CVE-2026-59930 allow?
CVE-2026-59930 allows for potential collision attacks with attacker-controlled `id="toc_N"` content.