Skip to content

Commit 4009f67

Browse files
committed
fix: use SAFE_PROTOCOLS instead of HARMFUL_PROTOCOLS
1 parent 3025549 commit 4009f67

3 files changed

Lines changed: 98 additions & 30 deletions

File tree

src/mistune/renderers/html.py

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, ClassVar, Dict, Optional, Tuple, Literal
1+
from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple, Union, Literal
22
from urllib.parse import unquote
33
from ..core import BaseRenderer, BlockState
44
from ..util import escape as escape_text
@@ -9,20 +9,17 @@ class HTMLRenderer(BaseRenderer):
99
"""A renderer for converting Markdown to HTML."""
1010

1111
_escape: bool
12+
_allow_harmful_protocols: Optional[Union[bool, Iterable[str]]]
1213
NAME: ClassVar[Literal["html"]] = "html"
13-
HARMFUL_PROTOCOLS: ClassVar[Tuple[str, ...]] = (
14-
"javascript:",
15-
"vbscript:",
16-
"file:",
17-
"data:",
18-
"feed:",
19-
"jar:",
20-
"livescript:",
21-
"mocha:",
22-
"ms-its:",
23-
"mk:",
24-
"res:",
25-
"view-source:",
14+
SAFE_PROTOCOLS: ClassVar[Tuple[str, ...]] = (
15+
"http:",
16+
"https:",
17+
"mailto:",
18+
"tel:",
19+
"ftp:",
20+
"ftps:",
21+
"irc:",
22+
"ircs:",
2623
)
2724
GOOD_DATA_PROTOCOLS: ClassVar[Tuple[str, ...]] = (
2825
"data:image/gif;",
@@ -31,7 +28,11 @@ class HTMLRenderer(BaseRenderer):
3128
"data:image/webp;",
3229
)
3330

34-
def __init__(self, escape: bool = True, allow_harmful_protocols: Optional[bool] = None) -> None:
31+
def __init__(
32+
self,
33+
escape: bool = True,
34+
allow_harmful_protocols: Optional[Union[bool, Iterable[str]]] = None,
35+
) -> None:
3536
super(HTMLRenderer, self).__init__()
3637
self._allow_harmful_protocols = allow_harmful_protocols
3738
self._escape = escape
@@ -59,16 +60,17 @@ def safe_url(self, url: str) -> str:
5960
"""Ensure the given URL is safe. This method is used for rendering
6061
links, images, and etc.
6162
"""
62-
if self._allow_harmful_protocols is True:
63+
allow_harmful_protocols = self._allow_harmful_protocols
64+
if allow_harmful_protocols is True:
6365
return escape_text(url)
6466

65-
_url = _unquote_url(url).lower()
66-
if self._allow_harmful_protocols and _url.startswith(tuple(self._allow_harmful_protocols)):
67+
_url = _unquote_url(url).lower().lstrip()
68+
if allow_harmful_protocols and _url.startswith(tuple(allow_harmful_protocols)):
6769
return escape_text(url)
6870

69-
if _url.startswith(self.HARMFUL_PROTOCOLS) and not _url.startswith(self.GOOD_DATA_PROTOCOLS):
70-
return "#harmful-link"
71-
return escape_text(url)
71+
if _is_safe_url(_url, self.SAFE_PROTOCOLS, self.GOOD_DATA_PROTOCOLS):
72+
return escape_text(url)
73+
return "#harmful-link"
7274

7375
def text(self, text: str) -> str:
7476
if self._escape:
@@ -169,3 +171,13 @@ def _unquote_url(url: str) -> str:
169171
break
170172
url = decoded
171173
return url
174+
175+
176+
def _is_safe_url(url: str, safe_protocols: Tuple[str, ...], good_data_protocols: Tuple[str, ...]) -> bool:
177+
if url.startswith(safe_protocols):
178+
return True
179+
if url.startswith(good_data_protocols):
180+
return True
181+
if url.startswith(("/", "#", "?")):
182+
return True
183+
return ":" not in url.split("/", 1)[0]

tests/test_commonmark.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33

44

55
class TestCommonMark(BaseTestCase):
6-
@classmethod
7-
def ignore_case(cls, n):
8-
return False
6+
markdown = mistune.create_markdown(
7+
renderer=mistune.HTMLRenderer(escape=False, allow_harmful_protocols=True),
8+
)
99

1010
def assert_case(self, n, text, html):
11-
result = mistune.html(text)
11+
result = self.markdown(text)
1212
self.assertEqual(normalize_html(result), normalize_html(html))
1313

1414

tests/test_security_urls.py

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,76 @@
11
from unittest import TestCase
22

3-
from mistune import create_markdown
3+
from mistune import create_markdown, html
44

55

66
class TestSafeUrlSecurity(TestCase):
7+
def test_known_harmful_url_schemes_are_blocked(self):
8+
for url in [
9+
"javascript:alert(1)",
10+
"vbscript:msgbox(1)",
11+
"file:///etc/passwd",
12+
"data:text/html;base64,PHNjcmlwdD4=",
13+
"feed:javascript:alert(1)",
14+
"livescript:alert(1)",
15+
"mocha:alert(1)",
16+
"view-source:javascript:alert(1)",
17+
"jar:javascript:alert(1)",
18+
"ms-its:javascript:alert(1)",
19+
"mk:@MSITStore:javascript:alert(1)",
20+
"res:javascript:",
21+
]:
22+
rendered = html(f"[h]({url})")
23+
self.assertIn('href="#harmful-link"', rendered, url)
24+
25+
def test_unknown_url_scheme_is_blocked(self):
26+
rendered = html("[h](x-javascript:alert(1))")
27+
28+
self.assertIn('href="#harmful-link"', rendered)
29+
30+
def test_reference_and_autolink_harmful_url_schemes_are_blocked(self):
31+
cases = [
32+
"[h][r]\n\n[r]: feed:javascript:alert(1)",
33+
"<feed:javascript:alert(1)>",
34+
"[h][r]\n\n[r]: x-javascript:alert(1)",
35+
"<x-javascript:alert(1)>",
36+
]
37+
for text in cases:
38+
rendered = create_markdown()(text)
39+
self.assertIn('href="#harmful-link"', rendered, text)
40+
741
def test_percent_encoded_harmful_url_scheme_is_blocked(self):
842
for text in [
943
"[h](javascript%3Aalert(1))",
1044
"[h](javascript%253Aalert(1))",
1145
"[h][r]\n\n[r]: javascript%3Aalert(1)",
1246
"![h](data%3Atext/html;base64,PHNjcmlwdD4=)",
1347
"[h](view-source:javascript:alert(1))",
48+
"[h](%20javascript%3Aalert(1))",
1449
]:
15-
html = create_markdown()(text)
16-
self.assertIn("#harmful-link", html, text)
50+
rendered = create_markdown()(text)
51+
self.assertIn("#harmful-link", rendered, text)
1752

1853
def test_safe_percent_encoded_data_image_is_allowed(self):
19-
html = create_markdown()("![h](data%3Aimage/png;base64,AAAA)")
20-
self.assertIn('src="data%3Aimage/png;base64,AAAA"', html)
54+
rendered = create_markdown()("![h](data%3Aimage/png;base64,AAAA)")
55+
self.assertIn('src="data%3Aimage/png;base64,AAAA"', rendered)
56+
57+
def test_safe_url_schemes_are_allowed(self):
58+
for url in [
59+
"http://example.com",
60+
"https://example.com",
61+
"mailto:user@example.com",
62+
"tel:+123456789",
63+
"ftp://example.com/file",
64+
"ftps://example.com/file",
65+
"irc://example.com/channel",
66+
"ircs://example.com/channel",
67+
"//example.com/path",
68+
"/path",
69+
"./path:with-colon",
70+
"../path:with-colon",
71+
"path/with:colon",
72+
"#fragment",
73+
"?query",
74+
]:
75+
rendered = html(f"[h]({url})")
76+
self.assertIn(f'href="{url}"', rendered, url)

0 commit comments

Comments
 (0)