See how mistune compares to other vendors in security performance
Summary
Type: Uncontrolled recursion via mutual include. The Include directive checks for direct self-reference (a.md cannot include a.md), but does not detect indirect cycles. Two markdown files that include each other (a.md → includes b.md → includes a.md) cause unbounded recursion until Python's stack limit fires RecursionError. The exception propagates out of the renderer and crashes the calling code. File: src/mistune/directives/include.py, lines 33-37 (the self-include check is the only cycle-detection logic). Root cause: the include logic only compares os.path.abspath(dest) == os.path.abspath(sourcefile). There is no per-render set of "files already included" that would catch transitive cycles. When a.md includes b.md, the recursive block.parse(newstate) call uses dest (b.md) as the new file, which then includes a.md (passing the self-check, because the immediate parent file is b.md, not a.md), which then includes b.md, and so on. Each recursion level adds Python frames; the default stack limit of 1000 frames trips after ~7-10 cycle iterations and Python raises RecursionError. Since the directive does not catch the exception, it propagates out of Markdown.parse() and surfaces in the calling code, crashing the request.
Affected Code
File: src/mistune/directives/include.py, lines 28-54.
python relpath = self.parsetitle(m) dest = os.path.join(os.path.dirname(sourcefile), relpath) dest = os.path.normpath(dest)
if os.path.abspath(dest) == os.path.abspath(sourcefile): # <-- only catches direct self-include return {"type": "blockerror", "raw": "Could not include self: " + relpath}
if not os.path.isfile(dest): return {"type": "blockerror", "raw": "Could not find file: " + relpath}
with open(dest, "rb") as f: content = f.read().decode(encoding)
ext = os.path.splitext(relpath)[1] if ext in {".md", ".markdown", ".mkd"}: newstate = block.statecls() newstate.env["file"] = dest newstate.process(content) block.parse(newstate) # <-- recursive parse, no cycle tracking return newstate.tokens
Why it's wrong: the cycle-detection check is one level deep. Multi-file cycles slip through trivially. Python's default recursion limit is 1000 frames, so a cycle of length 2 trips after a few hundred mutual includes; the exception is uncaught by the directive, propagating out of Markdown.call() and crashing whatever called it.
Exploit Chain
1. Application uses mistune with the Include directive enabled. Application accepts user-supplied markdown files (CMS, wiki, multi-user documentation platform, note-taking app, CI/CD doc renderer). 2. Attacker uploads two markdown files: - a.md: .. include:: b.md - b.md: .. include:: a.md 3. Renderer is invoked on a.md (or any markdown that references this pair). Include directive includes b.md, which includes a.md, which includes b.md, ... Each recursion adds Python frames. 4. After ~340 cycle iterations (depending on default sys.setrecursionlimit(1000) and the per-include frame depth), Python raises RecursionError: maximum recursion depth exceeded. 5. The exception is not caught by the directive. It propagates through block.parse, through Markdown.call, and into the application's request handler. If the application doesn't catch it explicitly, the request errors out (HTTP 500 in web contexts, crash in CLI tools).
Security Impact
Attacker capability: crash the rendering engine on demand by submitting any markdown that triggers the cycle. Repeated requests deny service. If the renderer is used in a hot path (per-page-view docs rendering, search-index regeneration, scheduled doc-export jobs), the cycle persists across the whole pipeline. Preconditions: application uses mistune with the Include directive enabled and renders user-supplied markdown that can reference other user-uploaded files. Attacker needs write access to two .md files in the include search path (or a single file including a known-recurring pair). Differential: PoC-verified against mistune@3.2.1:
python import os, mistune from mistune.directives import RSTDirective, Include
os.makedirs('/tmp/mistune-recur', existok=True) with open('/tmp/mistune-recur/a.md', 'w') as f: f.write('A\n\n.. include:: b.md') with open('/tmp/mistune-recur/b.md', 'w') as f: f.write('B\n\n.. include:: a.md')
md = mistune.createmarkdown(plugins=[RSTDirective([Include()])]) state = md.block.statecls() state.env['file'] = '/tmp/mistune-recur/a.md' md.parse('.. include:: b.md', state=state) RecursionError: maximum recursion depth exceeded
The patched build (with the suggested fix below) returns a blockerror token like the existing self-include check, instead of recursing forever.
Suggested Fix
Track included paths in state.env and reject any include that would re-enter a path already on the include stack:
diff --- a/src/mistune/directives/include.py +++ b/src/mistune/directives/include.py @@ -28,8 +28,18 @@ class Include(DirectivePlugin): relpath = self.parsetitle(m) - dest = os.path.join(os.path.dirname(sourcefile), relpath) - dest = os.path.normpath(dest) + base = os.path.realpath(os.path.dirname(sourcefile)) + dest = os.path.realpath(os.path.join(base, relpath)) + + # Track include stack across recursive parses to detect cycles. + includestack = state.env.setdefault("includestack", []) + if dest in includestack or dest == os.path.realpath(sourcefile): + return { + "type": "blockerror", + "raw": "Could not include (cycle): " + relpath, + }
- if os.path.abspath(dest) == os.path.abspath(sourcefile): - return { - "type": "blockerror", - "raw": "Could not include self: " + relpath, - } @@ ... in the markdown-include branch ... + includestack.append(dest) + try: + newstate = block.statecls() + newstate.env["file"] = dest + newstate.env["includestack"] = includestack + newstate.process(content) + block.parse(newstate) + return newstate.tokens + finally: + includestack.pop()
This catches cycles of any length (a → b → a, a → b → c → a, etc.). Pair this with the path-containment fix from the LFI advisory and the HTML-extension fix from the include-XSS advisory; together those three patches make the Include directive safe to enable on user-supplied markdown.
Add a regression test asserting that a 2-cycle and a 3-cycle both produce blockerror rather than RecursionError.
Summary
Type: Algorithmic-complexity DoS in reference-link definition handling. A markdown document with N reference-link definitions of the same key (or many distinct keys) takes O(N²) parser time. 5000 repeated [a]: u\n definitions take ~1.1 second; 10000 → ~4.5 seconds. File: src/mistune/blockparser.py (reference-link def parsing) and the surrounding reflinks env-dictionary handling. Root cause: every reference definition is parsed by scanning forward from each candidate position. The unikey normalisation runs per-def, the dictionary insert is per-def, and the lookup-by-label-then-iterate-defs path is linear in the number of stored defs. For input with N defs, the total work is O(N²).
Affected Code
src/mistune/blockparser.py — reference-definition rule fires on every line that matches [label]: url. For each one: - unikey(label) is called (linear scan of the label). - The def is appended to state.env['reflinks']. - Later inline-link resolution looks up by unikey(label) in the dict (O(1)) but the surrounding parser revisits the def list for paragraph-vs-def disambiguation.
The cumulative parse time grows as the square of the number of defs.
Why it's wrong: the parser does not amortise the def-list scan. A single forward pass with a hash-keyed dict (already in place) plus a per-line classifier should make this O(N).
Exploit Chain
1. Application uses mistune to render attacker-supplied markdown. No plugins required. 2. Attacker submits a 35 KB document of [a]: u\n repeated 5000 times followed by [click][a]. 3. CPU pegs for ~1.1 seconds. 10000 defs → ~4.5 s. 20000 → ~18 s. Doubling input quadruples time.
Security Impact
Attacker capability: small input → large CPU. Predictable scaling. Can be repeated. Preconditions: application uses mistune.createmarkdown() (default config) on attacker-supplied markdown. Worth noting: the reflinks dictionary persists for the lifetime of the parse, so a long document with many defs builds up memory; with N defs of attacker-chosen length, the per-def normalisation cost compounds. Differential: PoC-verified against mistune@3.2.1, default config:
python import mistune, time md = mistune.createmarkdown() for n in [1000, 2000, 5000, 10000]: s = '[a]: u\n' n + '[click][a]' t = time.time() md(s) print(f' ref defs {n} ({len(s)}b): {(time.time() - t) 1000:.0f}ms')
Output (Python 3.13, Linux, 2.5GHz CPU): ref defs 1000 ( 7012b): 46ms ref defs 2000 (14012b): 186ms ref defs 5000 (35012b): 1121ms ref defs 10000 (70012b): 4400ms
The patched build (with the surrounding parser amortised to O(N)) keeps the time linear.
Suggested Fix
Replace the per-def re-scan with a single forward pass that classifies each line into refdef | paragraph | other once and only inserts into reflinks once per def. The dict already exists; the wasted work is in the surrounding scan loop, not in the dict operations.
A regression test asserting that md('[a]: u\n' 50000 + '[click][a]') completes in under 1 second would catch any regression.
Summary
A path traversal issue exists in mistune's Include directive when markdown files are processed using md.read(). A crafted include path can cause files outside the intended markdown directory to be accessed.
Details
The issue occurs in the Include.parse() method where user-supplied paths are joined and normalized without verifying that the resulting path remains within an expected directory.
python relpath = self.parsetitle(m) dest = os.path.join(os.path.dirname(sourcefile), relpath) dest = os.path.normpath(dest)
Because the final path is not restricted to a trusted base directory, path traversal sequences such as ../ may reference files outside the intended location.
Proof of Concept
Create a markdown file:
markdown .. include:: ../../../example.txt
Process it using:
python import mistune from mistune.directives import RSTDirective, Include
md = mistune.createmarkdown( plugins=[RSTDirective([Include()])] )
result, state = md.read("test.md") print(result)
Impact
Applications that process untrusted markdown files with the Include directive enabled may allow unintended file access. The impact depends on how the feature is used and what files are accessible to the running process.
Recommended Fix
Validate the resolved path and ensure it remains within an allowed directory before opening the file.
Summary
Type: URL-scheme allowlist gap. The safeurl filter only blocks the four schemes javascript:, vbscript:, file:, data:. Several other schemes are accepted into rendered <a href="..."> and <img src="..."> tags despite being known XSS vectors in legacy or chain-handling browsers. The same gap applies to direct links, reference links, and autolinks. File: src/mistune/renderers/html.py, line 11-23 (HARMFULPROTOCOLS list). Root cause: the HARMFULPROTOCOLS tuple is a hardcoded, opt-out denylist of four entries. Browsers historically supported (and some still partially support) several other schemes that either execute JavaScript directly (livescript:, mocha:) or wrap a javascript: payload (feed:javascript:, view-source:javascript:, jar:javascript:, ms-its:javascript:, mk:@MSITStore:javascript:). On user-agents that still recognise these schemes (older Firefox builds for feed:/jar:, all Internet Explorer / Edge Legacy for ms-its:/mk:/res:, niche chrome-style browsers, browser extensions that register custom protocol handlers), clicking a link rendered by mistune executes attacker-controlled JavaScript in the page's origin.
Affected Code
File: src/mistune/renderers/html.py, lines 10-62.
python class HTMLRenderer(BaseRenderer): HARMFULPROTOCOLS: ClassVar[Tuple[str, ...]] = ( "javascript:", "vbscript:", "file:", "data:", ) # <-- BUG: incomplete denylist GOODDATAPROTOCOLS: ClassVar[Tuple[str, ...]] = ( "data:image/gif;", "data:image/png;", "data:image/jpeg;", "data:image/webp;", )
def safeurl(self, url: str) -> str: if self.allowharmfulprotocols is True: return escapetext(url) url = url.lower() if self.allowharmfulprotocols and url.startswith(tuple(self.allowharmfulprotocols)): return escapetext(url) if url.startswith(self.HARMFULPROTOCOLS) and not url.startswith(self.GOODDATAPROTOCOLS): return "#harmful-link" return escapetext(url) # <-- BUG: any scheme not in HARMFULPROTOCOLS passes through
Why it's wrong: an opt-out denylist for URL schemes is the wrong shape. The set of schemes a user-agent might honour is unbounded (registered handlers, browser extensions, OS-level protocol registrations, custom intent handlers on Android, etc.), but the set of schemes a markdown renderer needs to allow is small (http://, https://, mailto:, optionally tel:, ftp:, fragment-only #anchor, and a few image-only data: types). Switching to an opt-in allowlist with a safeextraprotocols knob for callers who need others would close every variant of this bug class permanently. The current code accepts every chained-scheme XSS vector for as long as the project remembers to keep the denylist current.
Exploit Chain
1. Application accepts attacker-supplied markdown and renders it with mistune. The default escape=True prevents raw HTML, but link href/image src filtering is the only XSS defense for click and !alt syntax. 2. Attacker writes click here). mistune's safeurl checks feed:javascript:alert(document.cookie) against HARMFULPROTOCOLS = ('javascript:', 'vbscript:', 'file:', 'data:') — none match. The href is escapetext'd (HTML-entity escape) and emitted as <a href="feed:javascript:alert(document.cookie)">click here</a>. 3. Victim using a Firefox build that still has the feed handler registered (extension, configuration, or LTS that retained the feed reader past the 64.0 removal — including some forks and ESR builds) clicks the link. Firefox's feed handler invokes the inner URL, which is javascript:alert(...). JS executes in the page's origin. Victim's session cookie is exfiltrated. 4. Same pattern for livescript:alert(1) (Netscape Communicator era, still recognised by some niche browsers / browser-emulator tools), view-source:javascript:alert(1) (Firefox, see CVE-2009-1938), jar:javascript:alert(1) (older Firefox), ms-its:javascript: (IE/Edge Legacy), res:javascript: (IE), mk:@MSITStore:javascript: (IE CHM viewer). Each user-agent that recognises one of these is exploitable; the user-agent population that recognises at least one is not negligible (corporate environments still running Edge Legacy compatibility mode, locked-down kiosk browsers, Android WebView in apps that register custom intent handlers, Linux distros with old Firefox ESR plus the feed: extension, etc.).
The same primitive applies to image src (! rendered as <img src="feed:...">) — though most browsers don't fetch javascript: from img src, the same chained handler quirk applies on a few user-agents — and to reference links and autolinks (verified in the PoC below; the rendered HTML is identical regardless of which markdown link syntax is used).
Security Impact
Severity: sec-moderate. Conditional XSS depending on user-agent. Modern Chrome / Edge Chromium / Safari ignore most of these schemes, but Firefox forks, Edge Legacy, in-app WebViews, browser extensions registering custom handlers, and corporate browser deployments are exposed. Defence-in-depth is the framing: a markdown renderer should not need to track which browsers still honour which legacy chained-scheme. Attacker capability: plant a link in any place the application renders user-supplied markdown. When clicked by a user-agent that honours the legacy scheme, the attacker's JavaScript runs in the page's origin (steal cookies, perform actions as the victim, etc.). Preconditions: application uses mistune to render attacker-influenced markdown. Default config. Victim user-agent is one of the affected populations. No specific mistune option is required. Differential: PoC-verified against mistune@3.2.1, default config. The following inputs all PASS the filter and reach the rendered HTML unchanged:
python import mistune md = mistune.createmarkdown() for url in [ 'feed:javascript:alert(1)', # Firefox feed handler chain 'livescript:alert(1)', # Netscape, niche browsers 'mocha:alert(1)', # Netscape, niche browsers 'view-source:javascript:alert(1)', # Firefox view-source chain (CVE-2009-1938 class) 'jar:javascript:alert(1)', # Firefox jar: handler chain 'ms-its:javascript:alert(1)', # IE/Edge Legacy InfoTech Storage handler 'mk:@MSITStore:javascript:alert(1)', # IE CHM viewer chain 'res:javascript:', # IE resource: handler ]: print(md(f'click').strip())
Output (each one passes the filter): <p><a href="feed:javascript:alert(1)">click</a></p> <p><a href="livescript:alert(1)">click</a></p> <p><a href="mocha:alert(1)">click</a></p> <p><a href="view-source:javascript:alert(1)">click</a></p> <p><a href="jar:javascript:alert(1)">click</a></p> <p><a href="ms-its:javascript:alert(1)">click</a></p> <p><a href="mk:@MSITStore:javascript:">click</a></p> <p><a href="res:javascript:">click</a></p>
For comparison, the four schemes already in the denylist are correctly blocked: javascript:, vbscript:, file:, data:text/html all return <a href="#harmful-link">.
The same gap applies to reference links ([click][ref]\n\n[ref]: feed:javascript:alert(1) → <a href="feed:javascript:alert(1)">) and to autolinks (<feed:javascript:alert(1)> → <a href="feed:javascript:alert(1)">).
Suggested Fix
Switch from denylist to allowlist. The set of schemes a markdown renderer needs to allow is small and well-known; the set of schemes that might trigger handler chains is unbounded.
diff --- a/src/mistune/renderers/html.py +++ b/src/mistune/renderers/html.py @@ -7,21 +7,28 @@ class HTMLRenderer(BaseRenderer):
escape: bool NAME: ClassVar[Literal["html"]] = "html" - HARMFULPROTOCOLS: ClassVar[Tuple[str, ...]] = ( - "javascript:", - "vbscript:", - "file:", - "data:", - ) + SAFEPROTOCOLS: ClassVar[Tuple[str, ...]] = ( + "http:", + "https:", + "mailto:", + "tel:", + "ftp:", + "ftps:", + "irc:", + "ircs:", + ) GOODDATAPROTOCOLS: ClassVar[Tuple[str, ...]] = ( "data:image/gif;", "data:image/png;", "data:image/jpeg;", "data:image/webp;", )
@@ -49,15 +56,21 @@ class HTMLRenderer(BaseRenderer): def safeurl(self, url: str) -> str: - if self.allowharmfulprotocols is True: - return escapetext(url) - - url = url.lower() - if self.allowharmfulprotocols and url.startswith(tuple(self.allowharmfulprotocols)): - return escapetext(url) - - if url.startswith(self.HARMFULPROTOCOLS) and not url.startswith(self.GOODDATAPROTOCOLS): - return "#harmful-link" - return escapetext(url) + # Allow-list: only schemes in SAFEPROTOCOLS, image-only data: URLs in + # GOODDATAPROTOCOLS, scheme-relative URLs (//host/path), absolute + # paths (/path), and anchor-only references (#fragment) reach the + # rendered output. Everything else is replaced with '#harmful-link'. + if self.allowharmfulprotocols is True: + return escapetext(url) + url = url.lower().lstrip() + if ( + url.startswith(self.SAFEPROTOCOLS) + or url.startswith(self.GOODDATAPROTOCOLS) + or url.startswith(("/", "#", "?")) + or ":" not in url.split("/", 1)[0] # bare relative path + ): + return escapetext(url) + if self.allowharmfulprotocols and url.startswith(tuple(self.allowharmfulprotocols)): + return escapetext(url) + return "#harmful-link"
The allowharmfulprotocols option is preserved, so callers who genuinely want to allow a custom scheme can still opt in. The lower().lstrip() also closes the leading-whitespace evasion sub-case (e.g., javascript: is already blocked by the current code via lower().startswith, but the same pattern needs to apply on the new allowlist branch). Add regression tests for each scheme listed in the PoC above asserting they resolve to #harmful-link.
Summary
Type: Algorithmic-complexity DoS in core emphasis parsing. A long sequence of well-formed x (strong) or x (strong-emphasis combined) pairs causes O(N²) parser work. Distinct from the bracket-bomb DoS ([ repetition) and from the formatting-plugin DoS (~~/==/^^); this one fires on default-config mistune with no plugins required. File: src/mistune/inlineparser.py lines 41-48 (the EMPHASISENDRE family) and the surrounding emphasis dispatch. Root cause: for every opening run of s the parser scans forward using one of EMPHASISENDRE[''] / [''] / [''] to find the matching close. Each scan is bounded per call, but the parser invokes the scan from every potential start position. For input shaped x repeated N times, every is treated as a potential start, each scan can cover up to the end of input. Total work is O(N²). The triple-emphasis variant x is slightly worse due to the extra alternation between , , and close patterns. Reproducible against default mistune with no plugins.
Affected Code
File: src/mistune/inlineparser.py, lines 41-48.
python EMPHASISENDRE = { "": re.compile(r"(?:" + PREVENTBACKSLASH + r"\\\|[^\s])\(?!\)"), "": re.compile(r"(?:" + PREVENTBACKSLASH + r"\\|[^\s])(?!)\b"), "": re.compile(r"(?:" + PREVENTBACKSLASH + r"\\\|[^\s])\\(?!\)"), "": re.compile(r"(?:" + PREVENTBACKSLASH + r"\\|[^\s])(?!)\b"), "": re.compile(r"(?:" + PREVENTBACKSLASH + r"\\\|[^\s])\\\(?!\)"), "": re.compile(r"(?:" + PREVENTBACKSLASH + r"\\|[^\s])(?!)\b"), } Each of the six end-patterns is invoked from every emphasis open position fired by the inline rule r"\{1,3}(?=[^\s])|\b{1,3}(?=[^\s])". The scan itself is bounded per call; the cost comes from the parser invoking the scan at every matching open marker, giving O(N²) total work.
Why it's wrong: same shape as the formatting-plugin and bracket-bomb DoS findings. The CommonMark reference parser handles emphasis in linear time using a delimiter-stack algorithm (commonmark.js, commonmark-py, markdown-it-py all do this). mistune retries the close-scan from each open marker. The bounded regex is not enough; the surrounding loop is the source of the quadratic.
Exploit Chain
1. Application uses mistune to render user-supplied markdown. No plugins required — affects the default mistune.createmarkdown() configuration. 2. Attacker submits a 40 KB payload of x repeated 8000 times. 3. Server CPU pegs for ~4 seconds; 16 KB → ~17 seconds. Doubling input quadruples time. 4. Repeating the request floods the worker pool.
Security Impact
Severity: sec-high. Network-reachable, no authentication, no plugin requirement. Default mistune is vulnerable. Attacker capability: O(N²) CPU cost from a single small input. Predictable scaling, easy to combine with concurrent requests for service denial. Preconditions: application uses mistune.createmarkdown() (default config) on attacker-supplied markdown. No plugins required. Differential: PoC-verified against mistune@3.2.1, default config:
python import mistune, time md = mistune.createmarkdown() # no plugins for n in [500, 1000, 2000, 4000, 8000]: s = 'x' n t = time.time() md(s) print(f' x {n} ({len(s)}b): {(time.time() - t) 1000:.0f}ms')
Output (Python 3.13, Linux, 2.5GHz CPU): x 500 (2500b): 20ms x 1000 (5000b): 74ms x 2000 (10000b): 284ms x 4000 (20000b): 1079ms x 8000 (40000b): 4309ms
Triple-emphasis is similar: md('x' 4000) # ~1500ms
Linear in N for non-emphasis input of comparable size: md('xxxxx' 8000) # 1ms (4000x faster)
The patched build (with the suggested fix below — delimiter-stack rewrite or hard cap on simultaneous open markers) keeps the time linear in N.
Suggested Fix
Cap the number of unmatched opening emphasis markers the parser will track simultaneously, treating the rest as literal text:
diff --- a/src/mistune/inlineparser.py +++ b/src/mistune/inlineparser.py @@ ... in the emphasis-handling code path + # Bound the number of open emphasis markers tracked. CommonMark gives + # no semantics to deeply nested unmatched emphasis; this cap turns the + # parser-level O(N^2) into O(N) for adversarial inputs while preserving + # behaviour on every realistic markdown document. + MAXOPENEMPHASIS = 100 + if openemphasiscount > MAXOPENEMPHASIS: + # treat remaining / as literal text + ...
The proper fix is a delimiter-stack pass, the same approach the formatting-plugin advisory and the bracket-bomb advisory recommend. All three DoS findings share the same algorithmic pattern; a single rewrite of the inline-token retry loop closes them together. Add a regression test asserting that md('x' 50000) completes in under 1 second.
In src/mistune/directives/admonition.py, the renderadmonition() function concatenates the :class: option directly into the HTML class attribute without escaping (lines 63-68).
This allows attribute injection and XSS even when HTMLRenderer(escape=True) is used.
The directive name parameter is safe (validated against whitelist), but the class option comes from raw user input.
Summary An XSS vulnerability in Mistune allows bypassing of safeurl() protections via percent-encoded javascript URIs.
Details The vulnerability exists in HTMLRenderer.safeurl() in Mistune.
The function is intended to block harmful URL schemes such as "javascript:" by checking the prefix of the provided URL:
url = url.lower() if url.startswith(self.HARMFULPROTOCOLS): return "#harmful-link"
However, the input URL is not URL-decoded before this check. Because of this, an attacker can use percent-encoding to bypass the filter. For example:
javascript%3Aalert(1)
Since "%3A" is not decoded to ":", the check does not detect the "javascript:" scheme.
When rendered in a browser, the URL is decoded, resulting in execution of arbitrary JavaScript upon user interaction.
This effectively bypasses Mistune's built-in safeurl() protection mechanism.
PoC 1. Install vulnerable version:
pip install mistune==3.2.0
2. Run the following code:
import mistune
markdown = mistune.createmarkdown() html = markdown("j)")
print(html)
3. Output:
<p><a href="javascript%3Aalert(1)">j</a></p>
4. Open the rendered HTML in a browser and click the link.
5. The browser decodes "%3A" into ":" and executes:
javascript:alert(1)
Impact This is a cross-site scripting (XSS) vulnerability.
An attacker can craft a malicious Markdown link that executes JavaScript in the victim's browser when clicked.
Impact includes: - Session hijacking (e.g., cookie theft) - Execution of arbitrary JavaScript in the victim's context - Potential account takeover depending on the application
This affects any application that renders user-controlled Markdown using Mistune without additional URL sanitization.
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.
Summary
Type: Algorithmic-complexity denial of service. A run of N closed pairs ~~x~~~~x~~... (or the analogous ==x== for mark, ^^x^^ for insert) causes O(N²) work in the formatting parser. With the strikethrough, mark, or insert plugin enabled, an 8 KB input pegs the CPU for ~4 seconds; 16 KB → ~17 seconds. File: src/mistune/plugins/formatting.py, lines 13-15 (the STRIKEEND / MARKEND / INSERTEND patterns and their per-position scan). Root cause: for each opening ~~/==/^^ the parser scans forward for the matching close pattern. The scan itself uses a bounded regex, but the parser tries the close-scan at every potential start position. For input shaped like ~~x~~ repeated N times, every ~~ is examined as a possible start, each scan covers up to the end of input. Total work is O(N²). Default config without these plugins handles the same input in linear time (4 ms for 4000 reps), confirming the cost is in the formatting plugin's per-marker scan, not in core parsing.
Affected Code
File: src/mistune/plugins/formatting.py, lines 12-16.
python STRIKEEND = re.compile(r"(?:" + PREVENTBACKSLASH + r"\\~|[^\s~])~~(?!~)") MARKEND = re.compile(r"(?:" + PREVENTBACKSLASH + r"\\=|[^\s=])==(?!=)") INSERTEND = re.compile(r"(?:" + PREVENTBACKSLASH + r"\\\^|[^\s^])\^\^(?!\^)") Each pattern is scanned forward from every start position fired by the corresponding inline rule. The end-pattern itself is bounded; the cost comes from the surrounding parser invoking the scan at every '~~' / '==' / '^^' token in the input, giving N starts × O(N) per scan = O(N^2) total.
Why it's wrong: the same algorithmic-complexity flaw class as [ / [a parsing in core: a per-token retry loop without memoisation of failed positions. Each formatting marker is tried as both a potential start and as a continuation. A linear-pass delimiter-stack algorithm (matching how commonmark-py and markdown-it-py handle emphasis) would do this work in O(N) total. The bounded regex on each individual scan does not bound the parser-level repetition.
Exploit Chain
1. Application uses mistune to render user-supplied markdown and has any of the formatting plugins enabled (plugins=['strikethrough'], ['mark'], ['insert'], or any superset). These plugins are commonly enabled because GitHub-flavoured-Markdown compatibility requires ~~strikethrough~~ and many editors emit ==highlighting== and ^^underline^^ shortcuts. 2. Attacker submits an 8 KB markdown payload of the form ~~x~~~~x~~~~x~~... (40 000 characters of ~~x~~ repeated 8000 times, or the analogous shape with == / ^^). 3. Server calls mistune.createmarkdown(plugins=['strikethrough'])(payload). CPU pegs for ~4 seconds; 16 KB → ~17 seconds; 32 KB → ~70 seconds. Pure CPU cost, no significant memory growth. 4. Repeating the request floods the worker pool. On a single-thread WSGI handler this is one request per outage; on a thread pool, a small number of concurrent attackers exhausts capacity.
Security Impact
Severity: sec-high. Network-reachable, no authentication, predictable scaling, single-payload primitive. Only requires a user-supplied markdown sink and a formatting plugin enabled — both are common. Attacker capability: small input → large CPU. Doubling input size quadruples CPU time. Sustained requests deny service to other users. Preconditions: application uses mistune with any of strikethrough, mark, or insert plugins enabled. Default config does NOT enable these (so the attack only fires against the substantial deployed population that turns them on for GFM/markdown-extra compatibility). Differential: PoC-verified against mistune@3.2.1:
python import mistune, time md = mistune.createmarkdown(plugins=['strikethrough']) for n in [500, 1000, 2000, 4000, 8000]: s = '~~x~~' n t = time.time() md(s) print(f' ~~x~~ {n} ({len(s)}b): {(time.time() - t) 1000:.0f}ms')
Output (Python 3.13, Linux, 2.5GHz CPU): ~~x~~ 500 (2500b): 19ms ~~x~~ 1000 (5000b): 71ms ~~x~~ 2000 (10000b): 272ms ~~x~~ 4000 (20000b): 1090ms ~~x~~ 8000 (40000b): 4302ms
Identical scaling for ==x== (mark) and ^^x^^ (insert): md = mistune.createmarkdown(plugins=['mark']) md('==x==' 4000) # ~1100ms md = mistune.createmarkdown(plugins=['insert']) md('^^x^^' 4000) # ~1080ms
Without the plugin, the same input parses in linear time: md = mistune.createmarkdown() # no plugins md('~~x~~' 4000) # 4ms (1000x faster)
The patched build (with the suggested fix below — either a delimiter-stack rewrite or a hard cap on the number of unmatched markers tracked) keeps the time linear in N.
Suggested Fix
The minimal fix is to cap the number of simultaneously-tracked unmatched markers, treating extras as literal text. The proper fix is a single-pass delimiter-stack algorithm matching the CommonMark reference implementation. Surgical patch:
diff --- a/src/mistune/plugins/formatting.py +++ b/src/mistune/plugins/formatting.py @@ ... in the parsestrikethrough / parsemark / parseinsert functions + # Bound the number of open markers the parser will track concurrently. + # Inputs with more than this many open ~~ / == / ^^ in flight are + # almost certainly adversarial; CommonMark gives no semantics to + # deeply nested unmatched markers. + MAXOPENMARKERS = 100 + if openmarkercount > MAXOPENMARKERS: + # treat remaining markers as literal text, do not invoke the + # forward-scan to find a close + ...
A regression test should assert that md('~~x~~' 50000) completes in under 1 second. The same fix shape applies to MARKEND and INSERTEND.
In versions 3.0.0a1 through 3.2.0 of Mistune, there is a ReDoS (Regular Expression Denial of Service) vulnerability in LINKTITLERE that allows an attacker who can supply Markdown for parsing to cause denial of service. The regular expression used for parsing link titles contains overlapping alternatives that can trigger catastrophic backtracking. In both the double-quoted and single-quoted branches, a backslash followed by punctuation can be matched either as an escaped punctuation sequence or as two ordinary characters, creating an ambiguous pattern inside a repeated group. If an attacker supplies Markdown containing repeated ! sequences with no closing quote, the regex engine explores an exponential number of backtracking paths. This is reachable through normal Markdown parsing of inline links and block link reference definitions. A small crafted input can therefore cause significant CPU consumption and make applications using Mistune unresponsive.