From 52effea407e1014d227cda9899fa9178187b4055 Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Mon, 7 Sep 2026 22:16:27 +0100
Subject: [PATCH 1/4] Fix optional semicolons on protocol separators passing
_safe_href check
---
lib/markdown2.py | 3 ++-
test/tm-cases/xss_issue721.html | 1 +
test/tm-cases/xss_issue721.opts | 1 +
test/tm-cases/xss_issue721.text | 1 +
4 files changed, 5 insertions(+), 1 deletion(-)
create mode 100644 test/tm-cases/xss_issue721.html
create mode 100644 test/tm-cases/xss_issue721.opts
create mode 100644 test/tm-cases/xss_issue721.text
diff --git a/lib/markdown2.py b/lib/markdown2.py
index bf7df1ef..1e17ae57 100755
--- a/lib/markdown2.py
+++ b/lib/markdown2.py
@@ -1602,7 +1602,8 @@ def _safe_href(self):
# omitted ['"<>] for XSS reasons
less_safe = r'#/\.!#$%&\(\)\+,/:;=\?@\[\]^`\{\}\|~'
# html encoded colon in a URL still functions as a normal colon, so need to detect those
- protocol_seperators = [':', ':', ':', ':']
+ # semicolon at the end is optional in browsers - see #721
+ protocol_seperators = [':', r':?', r':?', r':?']
# dot seperated hostname, optional port number, not followed by protocol seperator
domain = r'(?:[{}]+(?:\.[{}]+)*)(?:(?Click me
diff --git a/test/tm-cases/xss_issue721.opts b/test/tm-cases/xss_issue721.opts
new file mode 100644
index 00000000..54de31a8
--- /dev/null
+++ b/test/tm-cases/xss_issue721.opts
@@ -0,0 +1 @@
+{"safe_mode": "escape"}
\ No newline at end of file
diff --git a/test/tm-cases/xss_issue721.text b/test/tm-cases/xss_issue721.text
new file mode 100644
index 00000000..ce92094e
--- /dev/null
+++ b/test/tm-cases/xss_issue721.text
@@ -0,0 +1 @@
+[Click me](javascript:alert(origin))
\ No newline at end of file
From 72de0f54e96d86571e85778334f65ae37e771352 Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Mon, 7 Sep 2026 22:47:26 +0100
Subject: [PATCH 2/4] Tweak _is_auto_link to reject unsafe URLs in safe mode
---
lib/markdown2.py | 9 ++++++---
test/tm-cases/hash_html_blocks_orphaned_close_tags.html | 2 +-
test/tm-cases/xss_issue721.html | 2 ++
test/tm-cases/xss_issue721.text | 4 +++-
4 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/lib/markdown2.py b/lib/markdown2.py
index 1e17ae57..af67d9c2 100755
--- a/lib/markdown2.py
+++ b/lib/markdown2.py
@@ -1397,9 +1397,12 @@ def _escape_special_chars(self, text: str) -> str:
return ''.join(escaped)
def _is_auto_link(self, text):
- if ':' in text and self._auto_link_re.match(text):
- return True
- elif '@' in text and self._auto_email_link_re.match(text):
+ if ':' in text:
+ autolink_match = self._auto_link_re.match(text)
+ if autolink_match:
+ return self.safe_mode is None or self._safe_href.match(autolink_match.group(1))
+
+ if '@' in text and self._auto_email_link_re.match(text):
return True
return False
diff --git a/test/tm-cases/hash_html_blocks_orphaned_close_tags.html b/test/tm-cases/hash_html_blocks_orphaned_close_tags.html
index 372f357d..ff20b82f 100644
--- a/test/tm-cases/hash_html_blocks_orphaned_close_tags.html
+++ b/test/tm-cases/hash_html_blocks_orphaned_close_tags.html
@@ -6,6 +6,6 @@
-http:/onmouseover=alert(origin)
+<http:/onmouseover=alert(origin)>
-
diff --git a/test/tm-cases/xss_issue721.html b/test/tm-cases/xss_issue721.html
index dd4a74fc..e0012a84 100644
--- a/test/tm-cases/xss_issue721.html
+++ b/test/tm-cases/xss_issue721.html
@@ -1 +1,3 @@
Click me
+
+<http:|><x| oncontentvisibilityautostatechange=alert(origin) style=display:block;content-visibility:auto>
diff --git a/test/tm-cases/xss_issue721.text b/test/tm-cases/xss_issue721.text
index ce92094e..43da8d75 100644
--- a/test/tm-cases/xss_issue721.text
+++ b/test/tm-cases/xss_issue721.text
@@ -1 +1,3 @@
-[Click me](javascript:alert(origin))
\ No newline at end of file
+[Click me](javascript:alert(origin))
+
+
\ No newline at end of file
From d01e5ae84ab79b45252c337cc7ff3994409eff01 Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Mon, 7 Sep 2026 23:14:31 +0100
Subject: [PATCH 3/4] Fix escaped HTML comments not having contents properly
sanitized
---
lib/markdown2.py | 8 ++++++--
test/tm-cases/escaped_html_in_safe_mode.html | 2 +-
test/tm-cases/xss_issue721.html | 2 ++
test/tm-cases/xss_issue721.text | 4 +++-
4 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/lib/markdown2.py b/lib/markdown2.py
index af67d9c2..d8a00942 100755
--- a/lib/markdown2.py
+++ b/lib/markdown2.py
@@ -1454,7 +1454,10 @@ def _is_comment(token):
tokens.append(self._hash_span(self._sanitize_html(is_comment.group(3))))
elif self._is_unescaped_re.match(token) is None:
# if the HTML is escaped then escape any special chars and add the token as-is
- tokens.append(self._escape_special_chars(token))
+ tokens.append(
+ # HTML can be snuck into escaped comment bodies - #721
+ self._sanitize_html(self._escape_special_chars(token))
+ )
else:
tokens.append(self._hash_span(self._sanitize_html(token)))
elif is_html_markup and is_code:
@@ -1496,10 +1499,11 @@ def _sanitize_html(self, s: str) -> str:
return self.html_removed_text
elif self.safe_mode == "escape":
replacements = [
- ('&', '&'),
('<', '<'),
('>', '>'),
]
+ # use a smart ampersand sub to avoid re-sanitizing stuff like `<`
+ s = _AMPERSAND_RE.sub('&', s)
for before, after in replacements:
s = s.replace(before, after)
return s
diff --git a/test/tm-cases/escaped_html_in_safe_mode.html b/test/tm-cases/escaped_html_in_safe_mode.html
index ddb13060..7a78a535 100644
--- a/test/tm-cases/escaped_html_in_safe_mode.html
+++ b/test/tm-cases/escaped_html_in_safe_mode.html
@@ -1,3 +1,3 @@
<abc>
-<abc>
+<abc>
<why?
diff --git a/test/tm-cases/xss_issue721.html b/test/tm-cases/xss_issue721.html
index e0012a84..fc596d07 100644
--- a/test/tm-cases/xss_issue721.html
+++ b/test/tm-cases/xss_issue721.html
@@ -1,3 +1,5 @@
Click me
<http:|><x| oncontentvisibilityautostatechange=alert(origin) style=display:block;content-visibility:auto>
+
+<!--<img src onerror=alert(origin)//>-->
diff --git a/test/tm-cases/xss_issue721.text b/test/tm-cases/xss_issue721.text
index 43da8d75..4d8e8fcd 100644
--- a/test/tm-cases/xss_issue721.text
+++ b/test/tm-cases/xss_issue721.text
@@ -1,3 +1,5 @@
[Click me](javascript:alert(origin))
-
\ No newline at end of file
+
+
+\
From 350e34c755c3c1742b82717331d5ee08dc3af7fe Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Mon, 7 Sep 2026 23:20:43 +0100
Subject: [PATCH 4/4] Update changelog
---
CHANGES.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGES.md b/CHANGES.md
index 21fb4ca3..8f027a0e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -12,6 +12,7 @@
- [pull #713] Fix `header-ids` extra generating duplicate ids when a suffixed id collides with another header (#661)
- [pull #705] XSS fixes in links, images, and more
- [pull #720] Add `wiki-links` extra for `[[Page Name]]` style links (#221)
+- [pull #722] Harden URL safety checks and sanitization in safe mode (#721)
## python-markdown2 2.5.5