From fcf8bbd5948dbd409aa394459fd6a7024b438fe9 Mon Sep 17 00:00:00 2001
From: LuisFigueroaG
Date: Wed, 7 Oct 2026 13:19:42 -0300
Subject: [PATCH 1/3] Fix XSS from code spans in safe mode
Code spans are protected from HTML sanitization before the main
conversion, but the span gamut doesn't always pair backticks the same
way (a tag spanning multiple lines changes the pairing), so text that
was skipped as "code" could be output as raw HTML.
Replace any `<` in those protected spans with a hash that unescapes to
`<`, so they're safe however they end up being parsed while real
code spans still only get escaped once. Header ids and pygments
highlighting unescape the hash first so their output doesn't change.
Fixes #726
---
CHANGES.md | 1 +
lib/markdown2.py | 17 ++++++++++++-----
...de_code_spans_in_fenced_code_with_lexer.html | 4 ++++
...de_code_spans_in_fenced_code_with_lexer.opts | 1 +
...de_code_spans_in_fenced_code_with_lexer.tags | 1 +
...de_code_spans_in_fenced_code_with_lexer.text | 3 +++
test/tm-cases/xss_code_spans_issue726.html | 7 +++++++
test/tm-cases/xss_code_spans_issue726.opts | 1 +
test/tm-cases/xss_code_spans_issue726.text | 7 +++++++
9 files changed, 37 insertions(+), 5 deletions(-)
create mode 100644 test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.html
create mode 100644 test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.opts
create mode 100644 test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.tags
create mode 100644 test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.text
create mode 100644 test/tm-cases/xss_code_spans_issue726.html
create mode 100644 test/tm-cases/xss_code_spans_issue726.opts
create mode 100644 test/tm-cases/xss_code_spans_issue726.text
diff --git a/CHANGES.md b/CHANGES.md
index 8911b5cb..cfeed572 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -17,6 +17,7 @@
- [pull #720] Add `wiki-links` extra for `[[Page Name]]` style links (#221)
- [pull #722] Harden URL safety checks and sanitization in safe mode (#721)
- [pull #728] Fix XSS from zero-padded colon entities in link URLs (#726)
+- Fix XSS from code spans that the span gamut doesn't treat as code spans in safe mode (#726)
## python-markdown2 2.5.5
diff --git a/lib/markdown2.py b/lib/markdown2.py
index b3c29847..46ea8a28 100755
--- a/lib/markdown2.py
+++ b/lib/markdown2.py
@@ -1438,10 +1438,17 @@ def _is_comment(token):
return re.match(r'()', token)
# protect raw code spans from processing, as they can often contain anything that looks like HTML and
- # trips up the regex. These are encoded and processed later on anyway
+ # trips up the regex. These are encoded and processed later on anyway.
+ # The span gamut doesn't always agree that these are code spans
+ # (eg: a tag spanning lines changes how backticks pair up), so swap
+ # any `<` for a hash that unescapes to `<`. Nothing in here can
+ # then come out as raw HTML (#726), and real code spans still only
+ # get escaped once.
code_hashes = {}
+ lt_hash = _hash_text('<')
+ self._code_table['<'] = lt_hash
text = self._code_span_re.sub(
- lambda m: self._hash_span(m.string[m.start(): m.end()], code_hashes),
+ lambda m: self._hash_span(m.group(0).replace('<', lt_hash), code_hashes),
text
)
@@ -1726,7 +1733,8 @@ def _h_sub(self, match: re.Match[str]) -> str:
n = min(n + demote_headers, 6)
header_id_attr = ""
if "header-ids" in self.extras:
- header_id = self.header_id_from_text(header_group,
+ header_id = self.header_id_from_text(
+ self._unescape_special_chars(header_group),
self.extras["header-ids"].get('prefix'), n)
if header_id:
header_id_attr = ' id="%s"' % header_id
@@ -3672,8 +3680,7 @@ def _code_block_with_lexer_sub(
formatter_opts = self.md.extras['fenced-code-blocks'] or {}
def unhash_code(codeblock):
- for key, sanitized in list(self.md.html_spans.items()):
- codeblock = codeblock.replace(key, sanitized)
+ codeblock = self.md._unhash_html_spans(codeblock, code=True)
replacements = [
("&", "&"),
("<", "<"),
diff --git a/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.html b/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.html
new file mode 100644
index 00000000..d9db860b
--- /dev/null
+++ b/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.html
@@ -0,0 +1,4 @@
+
+
print`a<b`
+
+
diff --git a/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.opts b/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.opts
new file mode 100644
index 00000000..d5258749
--- /dev/null
+++ b/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.opts
@@ -0,0 +1 @@
+{'safe_mode': 'escape', 'extras': ['fenced-code-blocks']}
\ No newline at end of file
diff --git a/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.tags b/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.tags
new file mode 100644
index 00000000..e2282d2f
--- /dev/null
+++ b/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.tags
@@ -0,0 +1 @@
+fenced-code-blocks pygments
\ No newline at end of file
diff --git a/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.text b/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.text
new file mode 100644
index 00000000..4131fbb8
--- /dev/null
+++ b/test/tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer.text
@@ -0,0 +1,3 @@
+```python
+print `a<x><svg onload=alert(origin)
+
+
Code spans still come out escaped once: a<b, <b> and C:\<folder>
+
+
Header with a<b in it
diff --git a/test/tm-cases/xss_code_spans_issue726.opts b/test/tm-cases/xss_code_spans_issue726.opts
new file mode 100644
index 00000000..4cbd41b4
--- /dev/null
+++ b/test/tm-cases/xss_code_spans_issue726.opts
@@ -0,0 +1 @@
+{"safe_mode": "escape", "extras": ["header-ids"]}
\ No newline at end of file
diff --git a/test/tm-cases/xss_code_spans_issue726.text b/test/tm-cases/xss_code_spans_issue726.text
new file mode 100644
index 00000000..11c6c16c
--- /dev/null
+++ b/test/tm-cases/xss_code_spans_issue726.text
@@ -0,0 +1,7 @@
+`)>