From edeed8e544c5589f9d2ac45a0a1087ac3a3acd66 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 18:50:47 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20escape=20the=20colon?= =?UTF-8?q?=5Ffence=20block=20name=20in=20the=20class=20attribute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default renderer interpolated the block name into `class="block-..."` unescaped, after unescaping entities in the info string, so `:::a"onmouseover="alert(1)` (or its entity-encoded form) injected attributes and markup even with `html=False` (#143). --- mdit_py_plugins/colon_fence.py | 2 +- tests/test_colon_fence.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/mdit_py_plugins/colon_fence.py b/mdit_py_plugins/colon_fence.py index 70c95ed..185548c 100644 --- a/mdit_py_plugins/colon_fence.py +++ b/mdit_py_plugins/colon_fence.py @@ -153,7 +153,7 @@ def _render( return ( "
"
         + content
         + "
\n" diff --git a/tests/test_colon_fence.py b/tests/test_colon_fence.py index fee609b..3ddbf34 100644 --- a/tests/test_colon_fence.py +++ b/tests/test_colon_fence.py @@ -36,3 +36,30 @@ def test_plugin_parse(data_regression): ) ) data_regression.check([t.as_dict() for t in tokens]) + + +@pytest.mark.parametrize( + "source,expected", + [ + pytest.param( + ':::a"onmouseover="alert(1)\nx\n:::\n', + '
x\n'
+            "
\n", + id="raw-quote", + ), + pytest.param( + ":::a"><xss>\nx\n:::\n", + '
x\n
\n', + id="entity-encoded", + ), + ], +) +def test_block_name_is_escaped(source, expected): + """The block name must not break out of the ``class`` attribute (#143). + + With ``html=False`` the output must not carry markup from the source, + but the name used to be interpolated into the attribute unescaped + (and the entity-encoded form was unescaped first, then interpolated). + """ + md = MarkdownIt("commonmark", {"html": False}).use(colon_fence_plugin) + assert md.render(source) == expected From 878504f10b332da976a15077c4c6c1181dd4f0a7 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 18:51:18 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20escape=20the=20taskli?= =?UTF-8?q?sts=20label=5Fafter=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `label=True, label_after=True` the label was built from the item's source text and emitted as an `html_inline` token, so `- [ ] ` rendered a raw `` even with `html=False` (#143). The label text is now HTML-escaped. --- mdit_py_plugins/tasklists/__init__.py | 4 +++- tests/test_tasklists.py | 33 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/mdit_py_plugins/tasklists/__init__.py b/mdit_py_plugins/tasklists/__init__.py index d80f475..089bb5d 100644 --- a/mdit_py_plugins/tasklists/__init__.py +++ b/mdit_py_plugins/tasklists/__init__.py @@ -21,6 +21,7 @@ from uuid import uuid4 from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml from markdown_it.rules_core import StateCore from markdown_it.token import Token @@ -132,7 +133,8 @@ def end_label() -> Token: def after_label(content: str, checkbox_id: str) -> Token: token = Token("html_inline", "", 0) token.content = ( - f'' + '' ) token.attrs = {"for": checkbox_id} return token diff --git a/tests/test_tasklists.py b/tests/test_tasklists.py index 427c336..e3f2e07 100644 --- a/tests/test_tasklists.py +++ b/tests/test_tasklists.py @@ -1,4 +1,5 @@ from pathlib import Path +import re from textwrap import dedent from markdown_it import MarkdownIt @@ -32,3 +33,35 @@ def test_all(line, title, input, expected): text = md.render(input) print(text) assert text.rstrip() == expected.rstrip() + + +@pytest.mark.parametrize( + "source,label", + [ + pytest.param( + "- [ ] \n", + " <img src=x onerror=alert(1)>", + id="raw-html", + ), + pytest.param("- [x] a & b\n", " a & b", id="ampersand"), + pytest.param("- [ ] plain task\n", " plain task", id="plain"), + ], +) +def test_label_after_is_escaped(source, label): + """The ``label_after`` label must not carry raw HTML from the source (#143). + + The label is built from the item's source text and emitted as an + ``html_inline`` token, so it used to bypass ``html=False``. + """ + md = MarkdownIt("commonmark", {"html": False}).use( + tasklists_plugin, label=True, label_after=True + ) + text = re.sub(r"task-item-[0-9a-f-]+", "task-item-ID", md.render(source)) + checked = ' checked="checked"' if source.startswith("- [x]") else "" + assert text == ( + '\n" + ) From f7ea4ce5ce5e02d0327f8d9826708eecffc4c9a4 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 19:04:18 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20pin=20the=20escaped?= =?UTF-8?q?=20output=20for=20'&'=20and=20source=20entities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A colon_fence block name containing '&' now emits '&' (same attribute value), and a tasklists label_after source entity is shown as written. --- tests/test_colon_fence.py | 6 ++++++ tests/test_tasklists.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/tests/test_colon_fence.py b/tests/test_colon_fence.py index 3ddbf34..f03d949 100644 --- a/tests/test_colon_fence.py +++ b/tests/test_colon_fence.py @@ -52,6 +52,12 @@ def test_plugin_parse(data_regression): '
x\n
\n', id="entity-encoded", ), + pytest.param( + # benign: same attribute value as before, now well-formed + ":::a&b\nx\n:::\n", + '
x\n
\n', + id="ampersand", + ), ], ) def test_block_name_is_escaped(source, expected): diff --git a/tests/test_tasklists.py b/tests/test_tasklists.py index e3f2e07..7a21218 100644 --- a/tests/test_tasklists.py +++ b/tests/test_tasklists.py @@ -65,3 +65,17 @@ def test_label_after_is_escaped(source, label): f'' "\n\n" ) + + +def test_label_after_entity_is_shown_literally(): + """The ``label_after`` label is the item's raw source text, escaped (#143). + + So a source entity is shown as written (``&``), not decoded. + Only the label is asserted: the inline children before the last one + are also rendered outside the label, which is a separate, existing issue. + """ + md = MarkdownIt("commonmark", {"html": False}).use( + tasklists_plugin, label=True, label_after=True + ) + labels = re.findall(r"", md.render("- [ ] a & b\n")) + assert labels == [" a &amp; b"]