GHSA-2HM2-HC3V-44H9

Vulnerability from github – Published: 2026-07-20 21:35 – Updated: 2026-07-20 21:35
VLAI
Summary
Mistune toc / TableOfContents directive: heading IDs use predictable `toc_N` numbering with no slugification, allowing collision with attacker-controlled `id="toc_N"` content
Details

Summary

Type: Predictable identifier generation. The toc plugin and TableOfContents directive both default to generating heading IDs of the form toc_1, toc_2, toc_3, ... with no input-derived component. An attacker who can place a heading anywhere in the document can predict which toc_N ID it will receive, and can inject HTML elsewhere (in a non-heading context) that uses the same id="toc_N" to either (a) shadow the legitimate heading anchor, breaking same-page navigation, or (b) collide with CSS or JavaScript that targets #toc_N selectors, redirecting click handlers and styling to attacker-chosen content. File: src/mistune/toc.py line 36-37 (heading_id = lambda token, index: "toc_" + str(index + 1)); src/mistune/directives/toc.py line 33 (same default). Root cause: the default heading_id 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 toc_N IDs. Because id attribute uniqueness is required by HTML, browsers behaviour on duplicate IDs is undefined; document.getElementById('toc_1') returns the first match, getElementsByTagName + querySelector semantics differ across paths, and CSS rules targeting #toc_1 apply to whichever element matches first in tree order.

Affected Code

File: src/mistune/toc.py, lines 33-39.

def add_toc_hook(md, min_level=1, max_level=3, heading_id=None):
    if heading_id is None:
        def heading_id(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.

class TableOfContents(DirectivePlugin):
    def __init__(self, min_level=1, max_level=3):
        # ...

    def generate_heading_id(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](#toc_1) go to whichever element with id="toc_1" appears first in tree order. If the attacker can land any HTML element with id="toc_1" before the real heading (via inline_html with escape=False, via the include-directive HTML branch, via attacker-supplied content earlier in the document), navigation is hijacked. - CSS rules targeting #toc_1 apply to the wrong element. - JavaScript bound to document.getElementById('toc_1') operates on the wrong element. - The TOC's own <a href="#toc_1"> link in the rendered TOC list points to whichever element wins the duplicate-ID race.

Exploit Chain

  1. Application uses mistune with add_toc_hook(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="toc_1">...</a> anywhere in the document.
  3. mistune assigns id="toc_1" to the first heading. Now there are two elements with id="toc_1" in the page.
  4. The rendered TOC contains <a href="#toc_1">First heading</a>. Clicking it navigates to whichever element with id="toc_1" appears first in tree order. If the attacker placed their <a id="toc_1"> BEFORE the heading, navigation is hijacked.
  5. Same-page CSS / JS / aria-described references to #toc_1 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="toc_N" in the document, hijack <a href="#toc_N"> 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 heading_id callback. If the application provides its own heading_id (e.g., one based on slugified heading text, with collision suffixes), this finding does not apply. Differential: PoC-verified against mistune@3.2.1:

import mistune
from mistune.directives import RSTDirective, TableOfContents
md = mistune.create_markdown(plugins=[RSTDirective([TableOfContents()])])

print(md('''
.. toc::

# Heading 1

# Heading 2
'''))

# Output (note: id="toc_1" / id="toc_2", purely positional):
# <details class="toc" open>
#   <summary>Table of Contents</summary>
#   <ul>
#     <li><a href="#toc_1">Heading 1</a></li>
#     <li><a href="#toc_2">Heading 2</a></li>
#   </ul>
# </details>
# <h1 id="toc_1">Heading 1</h1>
# <h1 id="toc_2">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:

--- a/src/mistune/toc.py
+++ b/src/mistune/toc.py
@@ -33,9 +33,18 @@ def add_toc_hook(md, min_level=1, max_level=3, heading_id=None):
     if heading_id is None:
+        import re
+        _slug_re = re.compile(r"[^a-z0-9]+")
+        seen = {}
         def heading_id(token, index):
-            return "toc_" + str(index + 1)
+            text = striptags(md.renderer(md.inline(token["text"], {}), BlockState()))
+            slug = _slug_re.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 #toc_N 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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "mistune"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.3.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-59930"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-1284",
      "CWE-345"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-20T21:35:11Z",
    "nvd_published_at": "2026-07-08T17:17:28Z",
    "severity": "MODERATE"
  },
  "details": "## Summary\n\n**Type:** Predictable identifier generation. The `toc` plugin and `TableOfContents` directive both default to generating heading IDs of the form `toc_1`, `toc_2`, `toc_3`, ... with no input-derived component. An attacker who can place a heading anywhere in the document can predict which `toc_N` ID it will receive, and can inject HTML elsewhere (in a non-heading context) that uses the same `id=\"toc_N\"` to either (a) shadow the legitimate heading anchor, breaking same-page navigation, or (b) collide with CSS or JavaScript that targets `#toc_N` selectors, redirecting click handlers and styling to attacker-chosen content.\n**File:** `src/mistune/toc.py` line 36-37 (`heading_id = lambda token, index: \"toc_\" + str(index + 1)`); `src/mistune/directives/toc.py` line 33 (same default).\n**Root cause:** the default `heading_id` callback ignores the heading\u0027s text content and uses only the headings\u0027s order in the document. Two documents rendered together (or one document with attacker-influenced headings spliced into trusted content) produce overlapping `toc_N` IDs. Because `id` attribute uniqueness is required by HTML, browsers behaviour on duplicate IDs is undefined; `document.getElementById(\u0027toc_1\u0027)` returns the first match, `getElementsByTagName + querySelector` semantics differ across paths, and CSS rules targeting `#toc_1` apply to whichever element matches first in tree order.\n\n## Affected Code\n\n**File:** `src/mistune/toc.py`, lines 33-39.\n\n```python\ndef add_toc_hook(md, min_level=1, max_level=3, heading_id=None):\n    if heading_id is None:\n        def heading_id(token, index):\n            return \"toc_\" + str(index + 1)               # \u003c-- BUG: index-only ID, no slug derived from heading text\n```\n\n**File:** `src/mistune/directives/toc.py`, lines 32-33.\n\n```python\nclass TableOfContents(DirectivePlugin):\n    def __init__(self, min_level=1, max_level=3):\n        # ...\n\n    def generate_heading_id(self, token, index):\n        return \"toc_\" + str(index + 1)                   # \u003c-- BUG: same predictable scheme\n```\n\n**Why it\u0027s 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 \u2014 `\u003ch1 id=\"introduction\"\u003eIntroduction\u003c/h1\u003e` \u2014 with a numeric suffix appended only when slug collisions occur. mistune\u0027s default punts the slugification entirely and produces purely positional IDs that an attacker can predict in O(1).\n\nThe downstream impacts:\n- Same-page links with `[click](#toc_1)` go to whichever element with `id=\"toc_1\"` appears first in tree order. If the attacker can land any HTML element with `id=\"toc_1\"` before the real heading (via inline_html with `escape=False`, via the include-directive HTML branch, via attacker-supplied content earlier in the document), navigation is hijacked.\n- CSS rules targeting `#toc_1` apply to the wrong element.\n- JavaScript bound to `document.getElementById(\u0027toc_1\u0027)` operates on the wrong element.\n- The TOC\u0027s own `\u003ca href=\"#toc_1\"\u003e` link in the rendered TOC list points to whichever element wins the duplicate-ID race.\n\n## Exploit Chain\n\n1. Application uses mistune with `add_toc_hook(md)` or `TableOfContents` directive enabled (the documented setup for sites with TOC support).\n2. 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 `\u003ca id=\"toc_1\"\u003e...\u003c/a\u003e` anywhere in the document.\n3. mistune assigns `id=\"toc_1\"` to the first heading. Now there are two elements with `id=\"toc_1\"` in the page.\n4. The rendered TOC contains `\u003ca href=\"#toc_1\"\u003eFirst heading\u003c/a\u003e`. Clicking it navigates to whichever element with `id=\"toc_1\"` appears first in tree order. If the attacker placed their `\u003ca id=\"toc_1\"\u003e` BEFORE the heading, navigation is hijacked.\n5. Same-page CSS / JS / aria-described references to `#toc_1` similarly redirect.\n\n## Security Impact\n\n**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\".\n**Attacker capability:** with the ability to plant any HTML element with `id=\"toc_N\"` in the document, hijack `\u003ca href=\"#toc_N\"\u003e` 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.).\n**Preconditions:** application uses TOC + the default `heading_id` callback. If the application provides its own `heading_id` (e.g., one based on slugified heading text, with collision suffixes), this finding does not apply.\n**Differential:** PoC-verified against mistune@3.2.1:\n\n```python\nimport mistune\nfrom mistune.directives import RSTDirective, TableOfContents\nmd = mistune.create_markdown(plugins=[RSTDirective([TableOfContents()])])\n\nprint(md(\u0027\u0027\u0027\n.. toc::\n\n# Heading 1\n\n# Heading 2\n\u0027\u0027\u0027))\n\n# Output (note: id=\"toc_1\" / id=\"toc_2\", purely positional):\n# \u003cdetails class=\"toc\" open\u003e\n#   \u003csummary\u003eTable of Contents\u003c/summary\u003e\n#   \u003cul\u003e\n#     \u003cli\u003e\u003ca href=\"#toc_1\"\u003eHeading 1\u003c/a\u003e\u003c/li\u003e\n#     \u003cli\u003e\u003ca href=\"#toc_2\"\u003eHeading 2\u003c/a\u003e\u003c/li\u003e\n#   \u003c/ul\u003e\n# \u003c/details\u003e\n# \u003ch1 id=\"toc_1\"\u003eHeading 1\u003c/h1\u003e\n# \u003ch1 id=\"toc_2\"\u003eHeading 2\u003c/h1\u003e\n```\n\nThe 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.\n\n## Suggested Fix\n\nDefault to slugifying the heading text:\n\n```diff\n--- a/src/mistune/toc.py\n+++ b/src/mistune/toc.py\n@@ -33,9 +33,18 @@ def add_toc_hook(md, min_level=1, max_level=3, heading_id=None):\n     if heading_id is None:\n+        import re\n+        _slug_re = re.compile(r\"[^a-z0-9]+\")\n+        seen = {}\n         def heading_id(token, index):\n-            return \"toc_\" + str(index + 1)\n+            text = striptags(md.renderer(md.inline(token[\"text\"], {}), BlockState()))\n+            slug = _slug_re.sub(\"-\", text.lower()).strip(\"-\") or \"section\"\n+            n = seen.get(slug, 0)\n+            seen[slug] = n + 1\n+            return slug if n == 0 else f\"{slug}-{n}\"\n```\n\nSame change applies to `src/mistune/directives/toc.py:33`. Existing applications that have hardcoded `#toc_N` 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.",
  "id": "GHSA-2hm2-hc3v-44h9",
  "modified": "2026-07-20T21:35:12Z",
  "published": "2026-07-20T21:35:11Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/lepture/mistune/security/advisories/GHSA-2hm2-hc3v-44h9"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-59930"
    },
    {
      "type": "WEB",
      "url": "https://github.com/lepture/mistune/commit/c4093c4742ed0d10d9332fb8edb455869b7b581b"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/lepture/mistune"
    },
    {
      "type": "WEB",
      "url": "https://github.com/lepture/mistune/releases/tag/v3.3.0"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pypa/advisory-database/tree/main/vulns/mistune/PYSEC-2026-2218.yaml"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Mistune toc / TableOfContents directive: heading IDs use predictable `toc_N` numbering with no slugification, allowing collision with attacker-controlled `id=\"toc_N\"` content"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…