From c7101fcbb6e8790e8e39157c5ca2238fc6dd6cbc Mon Sep 17 00:00:00 2001
From: Hsiaoming Yang <me@lepture.com>
Date: Sun, 21 Jun 2026 21:12:07 +0900
Subject: [PATCH] fix(renderer): block encoded unsafe URL schemes

---
 src/mistune/renderers/html.py | 20 +++++++++++++++++++-
 tests/test_security_urls.py   | 20 ++++++++++++++++++++
 2 files changed, 39 insertions(+), 1 deletion(-)
 create mode 100644 tests/test_security_urls.py

diff --git a/src/mistune/renderers/html.py b/src/mistune/renderers/html.py
index af092cf..66efcaa 100644
--- a/src/mistune/renderers/html.py
+++ b/src/mistune/renderers/html.py
@@ -1,4 +1,5 @@
 from typing import Any, ClassVar, Dict, Optional, Tuple, Literal
+from urllib.parse import unquote
 from ..core import BaseRenderer, BlockState
 from ..util import escape as escape_text
 from ..util import safe_entity, striptags
@@ -14,6 +15,14 @@ class HTMLRenderer(BaseRenderer):
         "vbscript:",
         "file:",
         "data:",
+        "feed:",
+        "jar:",
+        "livescript:",
+        "mocha:",
+        "ms-its:",
+        "mk:",
+        "res:",
+        "view-source:",
     )
     GOOD_DATA_PROTOCOLS: ClassVar[Tuple[str, ...]] = (
         "data:image/gif;",
@@ -53,7 +62,7 @@ def safe_url(self, url: str) -> str:
         if self._allow_harmful_protocols is True:
             return escape_text(url)
 
-        _url = url.lower()
+        _url = _unquote_url(url).lower()
         if self._allow_harmful_protocols and _url.startswith(tuple(self._allow_harmful_protocols)):
             return escape_text(url)
 
@@ -151,3 +160,12 @@ def list(self, text: str, ordered: bool, **attrs: Any) -> str:
 
     def list_item(self, text: str) -> str:
         return "<li>" + text + "</li>\n"
+
+
+def _unquote_url(url: str) -> str:
+    for _ in range(3):
+        decoded = unquote(url)
+        if decoded == url:
+            break
+        url = decoded
+    return url
diff --git a/tests/test_security_urls.py b/tests/test_security_urls.py
new file mode 100644
index 0000000..7e5c5b5
--- /dev/null
+++ b/tests/test_security_urls.py
@@ -0,0 +1,20 @@
+from unittest import TestCase
+
+from mistune import create_markdown
+
+
+class TestSafeUrlSecurity(TestCase):
+    def test_percent_encoded_harmful_url_scheme_is_blocked(self):
+        for text in [
+            "[h](javascript%3Aalert(1))",
+            "[h](javascript%253Aalert(1))",
+            "[h][r]\n\n[r]: javascript%3Aalert(1)",
+            "![h](data%3Atext/html;base64,PHNjcmlwdD4=)",
+            "[h](view-source:javascript:alert(1))",
+        ]:
+            html = create_markdown()(text)
+            self.assertIn("#harmful-link", html, text)
+
+    def test_safe_percent_encoded_data_image_is_allowed(self):
+        html = create_markdown()("![h](data%3Aimage/png;base64,AAAA)")
+        self.assertIn('src="data%3Aimage/png;base64,AAAA"', html)
