From 3b96ec1c9e6974229796bc36aa9bc6fad6829b29 Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Thu, 14 May 2026 22:23:12 +0100
Subject: [PATCH 1/6] Fix XSS fromn HTML encoded colons in hrefs
---
lib/markdown2.py | 4 +++-
test/tm-cases/xss_smuggling_spans_in_image_attrs.html | 2 ++
test/tm-cases/xss_smuggling_spans_in_image_attrs.text | 4 +++-
3 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/lib/markdown2.py b/lib/markdown2.py
index dc698970..6683df7c 100755
--- a/lib/markdown2.py
+++ b/lib/markdown2.py
@@ -1537,8 +1537,10 @@ def _safe_href(self):
safe = r'-\w'
# 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 = [':', ':', ':', ':']
# dot seperated hostname, optional port number, not followed by protocol seperator
- domain = r'(?:[{}]+(?:\.[{}]+)*)(?:(?![<code>" onerror="alert(1)//</code>]()

+
+x
diff --git a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
index 4a5c25a8..12d54edb 100644
--- a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
+++ b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
@@ -2,4 +2,6 @@
![`" onerror="alert(1)//`]()
-
\ No newline at end of file
+
+
+[x](javascript:alert(origin))
\ No newline at end of file
From a11ce82fbb99c3f8b72711a141ee1a511a90846b Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Thu, 14 May 2026 22:24:25 +0100
Subject: [PATCH 2/6] Fix XSS from making javascript: hrefs look like domains
with ports
---
lib/markdown2.py | 2 +-
test/tm-cases/xss_smuggling_spans_in_image_attrs.html | 2 ++
test/tm-cases/xss_smuggling_spans_in_image_attrs.text | 4 +++-
3 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/lib/markdown2.py b/lib/markdown2.py
index 6683df7c..745d91f6 100755
--- a/lib/markdown2.py
+++ b/lib/markdown2.py
@@ -1540,7 +1540,7 @@ def _safe_href(self):
# html encoded colon in a URL still functions as a normal colon, so need to detect those
protocol_seperators = [':', ':', ':', ':']
# dot seperated hostname, optional port number, not followed by protocol seperator
- domain = r'(?:[{}]+(?:\.[{}]+)*)(?:(?
x
+
+x
diff --git a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
index 12d54edb..26edae4e 100644
--- a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
+++ b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
@@ -4,4 +4,6 @@

-[x](javascript:alert(origin))
\ No newline at end of file
+[x](javascript:alert(origin))
+
+[x](javascript:1/alert(origin))
\ No newline at end of file
From 82b4482b70a1718eef9a4d4fb2449c059949a5f0 Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Thu, 14 May 2026 22:39:11 +0100
Subject: [PATCH 3/6] Fix onerror XSS in image title attr
---
lib/markdown2.py | 2 ++
test/tm-cases/xss_smuggling_spans_in_image_attrs.html | 7 +++++++
test/tm-cases/xss_smuggling_spans_in_image_attrs.text | 5 ++++-
3 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/lib/markdown2.py b/lib/markdown2.py
index 745d91f6..4ba78a4f 100755
--- a/lib/markdown2.py
+++ b/lib/markdown2.py
@@ -3271,6 +3271,8 @@ def run(self, text: str):
.replace('*', self.md._escape_table['*'])
.replace('_', self.md._escape_table['_'])
)
+ if self.md.safe_mode:
+ title = self.md._hash_span(title)
title_str = f' title="{title}"'
else:
title_str = ''
diff --git a/test/tm-cases/xss_smuggling_spans_in_image_attrs.html b/test/tm-cases/xss_smuggling_spans_in_image_attrs.html
index 20e0cd4d..ccd398e7 100644
--- a/test/tm-cases/xss_smuggling_spans_in_image_attrs.html
+++ b/test/tm-cases/xss_smuggling_spans_in_image_attrs.html
@@ -7,3 +7,10 @@
x
x
+
+
+-
+
+
onerror=alert(origin) )
+
+
diff --git a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
index 26edae4e..3f025a00 100644
--- a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
+++ b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
@@ -6,4 +6,7 @@
[x](javascript:alert(origin))
-[x](javascript:1/alert(origin))
\ No newline at end of file
+[x](javascript:1/alert(origin))
+
+-
+-  onerror=alert(origin) )
\ No newline at end of file
From 456f8a97fa105b887e3c287ccdb0cc6eb53baa46 Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Sat, 23 May 2026 10:36:52 +0100
Subject: [PATCH 4/6] Fix incomplete recursive unhashing of spans
Issue was a while loop comparison. We did `orig != text` but assigned `orig = text` at the end of the loop,
where it should have been at the start, before any transformations take place
---
lib/markdown2.py | 2 +-
test/tm-cases/xss_smuggling_spans_in_image_attrs.html | 3 +++
test/tm-cases/xss_smuggling_spans_in_image_attrs.text | 5 ++++-
3 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/lib/markdown2.py b/lib/markdown2.py
index 4ba78a4f..b09fd352 100755
--- a/lib/markdown2.py
+++ b/lib/markdown2.py
@@ -1421,13 +1421,13 @@ def _unhash_html_spans(self, text: str, spans=True, code=False) -> str:
'''
orig = ''
while text != orig:
+ orig = text
if spans:
for key, sanitized in list(self.html_spans.items()):
text = text.replace(key, sanitized)
if code:
for code, key in list(self._code_table.items()):
text = text.replace(key, code)
- orig = text
return text
def _sanitize_html(self, s: str) -> str:
diff --git a/test/tm-cases/xss_smuggling_spans_in_image_attrs.html b/test/tm-cases/xss_smuggling_spans_in_image_attrs.html
index ccd398e7..47abd2f8 100644
--- a/test/tm-cases/xss_smuggling_spans_in_image_attrs.html
+++ b/test/tm-cases/xss_smuggling_spans_in_image_attrs.html
@@ -14,3 +14,6 @@
onerror=alert(origin) )
+
+"></code)
diff --git a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
index 3f025a00..5b2eeb35 100644
--- a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
+++ b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
@@ -9,4 +9,7 @@
[x](javascript:1/alert(origin))
-
--  onerror=alert(origin) )
\ No newline at end of file
+-  onerror=alert(origin) )
+
+
From c173c1274419bc4a8a685ea180aa002bf172c68a Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Sat, 23 May 2026 10:56:09 +0100
Subject: [PATCH 5/6] Update github actions versions
---
.github/workflows/python.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml
index 40ce721a..2ca433a4 100644
--- a/.github/workflows/python.yaml
+++ b/.github/workflows/python.yaml
@@ -15,9 +15,9 @@ jobs:
- macos-latest
- windows-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
From b0dd0b3b5a078e4f3b6e2ee2d9b6c9414fba0ce4 Mon Sep 17 00:00:00 2001
From: Crozzers
Date: Sun, 24 May 2026 09:58:54 +0100
Subject: [PATCH 6/6] Fix smuggling XSS into link def URLs
---
lib/markdown2.py | 14 ++++++--------
.../xss_smuggling_spans_in_image_attrs.html | 2 ++
.../xss_smuggling_spans_in_image_attrs.text | 3 +++
3 files changed, 11 insertions(+), 8 deletions(-)
diff --git a/lib/markdown2.py b/lib/markdown2.py
index b09fd352..ffaa0527 100755
--- a/lib/markdown2.py
+++ b/lib/markdown2.py
@@ -1518,6 +1518,12 @@ def _protect_url(self, url: str) -> str:
mime = data_url.group('mime') or ''
if mime.startswith('image/') and data_url.group('token') == ';base64':
charset='base64'
+ else:
+ url = (
+ self._unhash_html_spans(url, code=True)
+ .replace('*', self._escape_table['*'])
+ .replace('_', self._escape_table['_'])
+ )
url = _html_escape_url(url, safe_mode=self.safe_mode, charset=charset)
key = _hash_text(url)
self._escape_table[url] = key
@@ -3236,7 +3242,6 @@ def run(self, text: str):
continue
text, url, title, url_end_idx = parsed
- url = self.md._unhash_html_spans(url, code=True)
# reference anchor or reference img
else:
if not self.options.get('ref', True):
@@ -3255,13 +3260,6 @@ def run(self, text: str):
curr_pos = p
continue
- # -- Encode and hash the URL and title to avoid conflicts with italics/bold
-
- url = (
- url
- .replace('*', self.md._escape_table['*'])
- .replace('_', self.md._escape_table['_'])
- )
if title:
if self.md.safe_mode:
# expose span contents for escaping - fix #691, #703
diff --git a/test/tm-cases/xss_smuggling_spans_in_image_attrs.html b/test/tm-cases/xss_smuggling_spans_in_image_attrs.html
index 47abd2f8..985a1545 100644
--- a/test/tm-cases/xss_smuggling_spans_in_image_attrs.html
+++ b/test/tm-cases/xss_smuggling_spans_in_image_attrs.html
@@ -17,3 +17,5 @@
"></code)
+
+">`)
diff --git a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
index 5b2eeb35..3693c6c5 100644
--- a/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
+++ b/test/tm-cases/xss_smuggling_spans_in_image_attrs.text
@@ -13,3 +13,6 @@

+
+![x](<"`"![x][id]
+[id]: x "`