diff --git a/mdit_py_plugins/attrs/index.py b/mdit_py_plugins/attrs/index.py index fed8bed..36def14 100644 --- a/mdit_py_plugins/attrs/index.py +++ b/mdit_py_plugins/attrs/index.py @@ -2,6 +2,7 @@ from collections.abc import Sequence from functools import partial +import re from typing import Any from markdown_it import MarkdownIt @@ -62,10 +63,24 @@ def attrs_plugin( :param allowed: A list of allowed attribute names. If not ``None``, any attributes not in this list will be removed and placed in the token's meta under the key "insecure_attrs". + If ``None`` (the default), event-handler attributes + (any name starting with ``on``, such as ``onclick``) are still removed, + as a baseline protection against script injection. + Other attributes, including ``style``, are kept. + Whatever ``allowed`` says, the URL-valued attributes + ``href``, ``src``, ``action``, ``formaction`` and ``xlink:href`` + are removed when ``md.validateLink`` rejects their value + (for example a ``javascript:`` URL), as for Markdown links and images. + This is not a full sanitiser: to safely render untrusted input, + pass an explicit ``allowed`` list (e.g. ``("id", "class")``). """ if spans: - md.inline.ruler.after(span_after, "span", _span_rule) + md.inline.ruler.after( + span_after, + "span", + partial(_span_rule, allowed=None if allowed is None else set(allowed)), + ) if after: md.inline.ruler.push( "attr", @@ -97,6 +112,16 @@ def attrs_block_plugin(md: MarkdownIt, *, allowed: Sequence[str] | None = None) :param allowed: A list of allowed attribute names. If not ``None``, any attributes not in this list will be removed and placed in the token's meta under the key "insecure_attrs". + If ``None`` (the default), event-handler attributes + (any name starting with ``on``, such as ``onclick``) are still removed, + as a baseline protection against script injection. + Other attributes, including ``style``, are kept. + Whatever ``allowed`` says, the URL-valued attributes + ``href``, ``src``, ``action``, ``formaction`` and ``xlink:href`` + are removed when ``md.validateLink`` rejects their value + (for example a ``javascript:`` URL), as for Markdown links and images. + This is not a full sanitiser: to safely render untrusted input, + pass an explicit ``allowed`` list (e.g. ``("id", "class")``). """ md.block.ruler.before("fence", "attr", _attr_block_rule) md.core.ruler.after( @@ -121,7 +146,9 @@ def _find_opening(tokens: Sequence[Token], index: int) -> int | None: return None -def _span_rule(state: StateInline, silent: bool) -> bool: +def _span_rule( + state: StateInline, silent: bool, *, allowed: set[str] | None = None +) -> bool: if state.src[state.pos] != "[": return False @@ -150,7 +177,7 @@ def _span_rule(state: StateInline, silent: bool) -> bool: state.pos = labelStart state.posMax = labelEnd token = state.push("span_open", "span", 1) - token.attrs = attrs # type: ignore[assignment] + _add_attrs(state.md, token, attrs, allowed) state.md.inline.tokenize(state) token = state.push("span_close", "span", -1) @@ -183,7 +210,7 @@ def _attr_inline_rule( attr_token = state.tokens[token_index] if "class" in attrs and "class" in attr_token.attrs: attrs["class"] = f"{attr_token.attrs['class']} {attrs['class']}" - _add_attrs(attr_token, attrs, allowed) + _add_attrs(state.md, attr_token, attrs, allowed) return True @@ -260,23 +287,69 @@ def _attr_resolve_block_rule(state: StateCore, *, allowed: set[str] | None) -> N if key == "class" or key not in next_token.attrs: next_token.attrs[key] = value else: - _add_attrs(next_token, state.tokens[i].attrs, allowed) + _add_attrs(state.md, next_token, state.tokens[i].attrs, allowed) state.tokens.pop(i) len_tokens -= 1 +_URL_ATTRS = frozenset(("href", "src", "action", "formaction", "xlink:href")) +"""Attributes whose value is a URL, checked with ``md.validateLink``.""" + +_URL_IGNORED_RE = re.compile(r"[\t\n\r]") +_URL_STRIP = "".join(chr(i) for i in range(0x21)) + + +def _is_insecure_attr(key: str) -> bool: + """Return True for event-handler attributes (``on*``, e.g. ``onclick``). + + They execute JavaScript, so they are removed by default + when no explicit ``allowed`` list is given. + """ + return key.lower().startswith("on") + + +def _is_valid_url(md: MarkdownIt, value: Any) -> bool: + """Return True if ``md.validateLink`` accepts a URL-valued attribute. + + Browsers ignore ASCII tabs and newlines anywhere in a URL, + and control characters and spaces around it, before reading its scheme + (so ``javascript:`` is a ``javascript:`` URL). + The value is emitted as written, so those characters are removed + before it is normalised and validated. + """ + url = _URL_IGNORED_RE.sub("", str(value)).strip(_URL_STRIP) + return md.validateLink(md.normalizeLink(url)) + + def _add_attrs( + md: MarkdownIt, token: Token, attrs: dict[str, Any], allowed: set[str] | None, ) -> None: - """Add attributes to a token, skipping any disallowed attributes.""" - if allowed is not None and ( - disallowed := {k: v for k, v in attrs.items() if k not in allowed} - ): + """Add attributes to a token, skipping any disallowed attributes. + + When ``allowed`` is not ``None`` only those names are kept. + When it is ``None`` (the default) event-handler (``on*``) attributes + are still removed, so untrusted input cannot inject script. + In both cases, a URL-valued attribute (``href``, ``src``, ...) + is removed if ``md.validateLink`` rejects it. + Anything removed is preserved in ``token.meta["insecure_attrs"]``. + """ + if allowed is not None: + disallowed = {k: v for k, v in attrs.items() if k not in allowed} + else: + disallowed = {k: v for k, v in attrs.items() if _is_insecure_attr(k)} + disallowed.update( + (k, v) + for k, v in attrs.items() + if k not in disallowed and k.lower() in _URL_ATTRS and not _is_valid_url(md, v) + ) + + if disallowed: token.meta["insecure_attrs"] = disallowed - attrs = {k: v for k, v in attrs.items() if k in allowed} + attrs = {k: v for k, v in attrs.items() if k not in disallowed} # attributes takes precedence over existing attributes token.attrs.update(attrs) diff --git a/mdit_py_plugins/dollarmath/index.py b/mdit_py_plugins/dollarmath/index.py index 2402bbd..c773c54 100644 --- a/mdit_py_plugins/dollarmath/index.py +++ b/mdit_py_plugins/dollarmath/index.py @@ -98,7 +98,7 @@ def dollarmath_plugin( if label_renderer is None: _label_renderer = ( # noqa: E731 lambda label: ( - f'' + f'' ) ) else: @@ -142,7 +142,7 @@ def render_math_block_label( env: EnvType, ) -> str: content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) - _id = tokens[idx].info + _id = escapeHtml(tokens[idx].info) label = _label_renderer(tokens[idx].info) return f'
\n{label}\n{content}\n
\n' diff --git a/mdit_py_plugins/myst_blocks/index.py b/mdit_py_plugins/myst_blocks/index.py index 11fc0fb..14fbbd3 100644 --- a/mdit_py_plugins/myst_blocks/index.py +++ b/mdit_py_plugins/myst_blocks/index.py @@ -152,7 +152,7 @@ def render_myst_target( options: OptionsDict, env: EnvType, ) -> str: - label = tokens[idx].content + label = escapeHtml(tokens[idx].content) class_name = "myst-target" target = f'({label})=' return f'
{target}
' diff --git a/mdit_py_plugins/texmath/index.py b/mdit_py_plugins/texmath/index.py index 1847ba1..73b1bb5 100644 --- a/mdit_py_plugins/texmath/index.py +++ b/mdit_py_plugins/texmath/index.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, TypedDict from markdown_it import MarkdownIt -from markdown_it.common.utils import charCodeAt +from markdown_it.common.utils import charCodeAt, escapeHtml if TYPE_CHECKING: from markdown_it.renderer import RendererProtocol @@ -65,7 +65,8 @@ def render_math_block( env: EnvType, ) -> str: return rule_block["tmpl"].format( # noqa: B023 - render(tokens[idx].content, True, macros), tokens[idx].info + render(tokens[idx].content, True, macros), + escapeHtml(tokens[idx].info), ) md.add_render_rule(rule_block["name"], render_math_block) @@ -172,7 +173,7 @@ def dollar_post(src: str, end: int) -> bool: def render(tex: str, displayMode: bool, macros: Any) -> str: - return tex + return escapeHtml(tex) # TODO better HTML renderer port for math # try: # res = katex.renderToString(tex,{throwOnError:False,displayMode,macros}) diff --git a/tests/fixtures/myst_block.md b/tests/fixtures/myst_block.md index 1ec05e3..515daf4 100644 --- a/tests/fixtures/myst_block.md +++ b/tests/fixtures/myst_block.md @@ -52,7 +52,7 @@ Target characters: . (a bc |@<>*./_-+:)= . -
(a bc |@<>*./_-+:)=
+
(a bc |@<>*./_-+:)=
. Empty target: diff --git a/tests/fixtures/texmath_bracket.md b/tests/fixtures/texmath_bracket.md index 97b3b7d..32644ef 100644 --- a/tests/fixtures/texmath_bracket.md +++ b/tests/fixtures/texmath_bracket.md @@ -23,7 +23,7 @@ simple equation including special html character. (valid=True) . \(1+1<3\) . -

1+1<3

+

1+1<3

. equation including backslashes. (valid=True) @@ -239,8 +239,8 @@ multiline equation. (valid=True) .
\\begin{matrix} - f & = & 2 + x + 3 \\ - & = & 5 + x + f & = & 2 + x + 3 \\ + & = & 5 + x \\end{matrix}
. @@ -253,7 +253,7 @@ vector equation. (valid=True) .
\\begin{pmatrix}x_2 \\\\ y_2 \\end{pmatrix} = -\\begin{pmatrix} A & B \\\\ C & D \\end{pmatrix}\\cdot +\\begin{pmatrix} A & B \\\\ C & D \\end{pmatrix}\\cdot \\begin{pmatrix} x_1 \\\\ y_1 \\end{pmatrix}
. diff --git a/tests/fixtures/texmath_dollar.md b/tests/fixtures/texmath_dollar.md index 64cad8a..51fdb40 100644 --- a/tests/fixtures/texmath_dollar.md +++ b/tests/fixtures/texmath_dollar.md @@ -23,7 +23,7 @@ simple equation including special html character. (valid=True) . $1+1<3$ . -

1+1<3

+

1+1<3

. equation including backslashes. (valid=True) @@ -239,8 +239,8 @@ $$\\begin{matrix} .
\\begin{matrix} - f & = & 2 + x + 3 \\ - & = & 5 + x + f & = & 2 + x + 3 \\ + & = & 5 + x \\end{matrix}
. @@ -253,7 +253,7 @@ $$\\begin{pmatrix}x_2 \\\\ y_2 \\end{pmatrix} = .
\\begin{pmatrix}x_2 \\\\ y_2 \\end{pmatrix} = -\\begin{pmatrix} A & B \\\\ C & D \\end{pmatrix}\\cdot +\\begin{pmatrix} A & B \\\\ C & D \\end{pmatrix}\\cdot \\begin{pmatrix} x_1 \\\\ y_1 \\end{pmatrix}
. diff --git a/tests/test_attrs.py b/tests/test_attrs.py index 6196f85..1615ded 100644 --- a/tests/test_attrs.py +++ b/tests/test_attrs.py @@ -40,6 +40,272 @@ def test_attrs_allowed(data_regression): data_regression.check([t.as_dict() for t in tokens]) +@pytest.mark.parametrize( + "src,leaked", + [ + ('[x]{onclick="alert(1)"}', "onclick"), + ('[x]{OnClick="alert(1)"}', "onclick"), # case-insensitive + ('![a](i.png){onerror="alert(1)"}', "onerror"), + ], +) +def test_event_handler_stripped_by_default(src, leaked): + """With no ``allowed`` list, on* attributes are removed (XSS).""" + md = MarkdownIt("commonmark").use(attrs_plugin, spans=True).use(attrs_block_plugin) + text = md.render(src) + assert leaked.lower() not in text.lower() + + +def test_block_event_handler_stripped_by_default(): + md = MarkdownIt("commonmark").use(attrs_plugin).use(attrs_block_plugin) + text = md.render('{onmouseover="alert(1)"}\nparagraph\n') + assert "onmouseover" not in text + + +def test_event_handler_moved_to_meta(): + """A removed on* attribute is kept in ``token.meta["insecure_attrs"]``.""" + md = MarkdownIt("commonmark").use(attrs_block_plugin) + tokens = md.parse("{#a onclick=x}\nparagraph\n") + assert tokens[0].type == "paragraph_open" + assert tokens[0].attrs == {"id": "a"} + assert tokens[0].meta == {"insecure_attrs": {"onclick": "x"}} + + +@pytest.mark.parametrize( + "src,token_type,expected", + [ + pytest.param( + "{style=lower-alpha}\n1. a\n", + "ordered_list_open", + "lower-alpha", + id="block-list", + ), + pytest.param('[x]{style="color:red"}', "span_open", "color:red", id="span"), + pytest.param( + '![a](i.png){style="width:1em"}', "image", "width:1em", id="image" + ), + ], +) +def test_style_kept_by_default(src, token_type, expected): + """With no ``allowed`` list, ``style`` is kept (it does not run script). + + MyST-Parser reads ``{style=lower-alpha}`` on an ordered list + to choose its numbering, so dropping it would break documents. + """ + md = MarkdownIt("commonmark").use(attrs_plugin, spans=True).use(attrs_block_plugin) + tokens = md.parse(src) + found = [t for t in tokens if t.type == token_type] + found += [c for t in tokens for c in (t.children or []) if c.type == token_type] + assert len(found) == 1 + assert found[0].attrs.get("style") == expected + assert "insecure_attrs" not in found[0].meta + assert f'style="{expected}"' in md.render(src) + + +def test_benign_attrs_preserved_by_default(): + """The default baseline only strips on*, not benign attrs.""" + md = MarkdownIt("commonmark").use(attrs_plugin, spans=True) + text = md.render("[x]{#a .b width=100 data-y=z}") + assert 'id="a"' in text + assert 'width="100"' in text + assert 'data-y="z"' in text + + +def test_span_respects_allowed_list(): + """Spans now honour an explicit allow-list (previously bypassed entirely).""" + md = MarkdownIt("commonmark").use(attrs_plugin, spans=True, allowed=["id", "class"]) + text = md.render("[x]{#a .b width=100 onclick=e}") + assert 'id="a"' in text + assert "width" not in text + assert "onclick" not in text + + +@pytest.mark.parametrize( + "src,expected,removed", + [ + pytest.param( + '[a](https://x){href="javascript:alert(1)"}', + '

a

\n', + {"href": "javascript:alert(1)"}, + id="link-href", + ), + pytest.param( + '[a](https://x){HREF="JavaScript:alert(1)"}', + '

a

\n', + {"HREF": "JavaScript:alert(1)"}, + id="link-href-case", + ), + pytest.param( + '[a](https://x){href="java\tscript:alert(1)"}', + '

a

\n', + {"href": "java\tscript:alert(1)"}, + id="link-href-tab", + ), + pytest.param( + '[a](https://x){href="\x01 javascript:alert(1)"}', + '

a

\n', + {"href": "\x01 javascript:alert(1)"}, + id="link-href-leading-control", + ), + pytest.param( + '![i](x.png){src="javascript:alert(1)"}', + '

i

\n', + {"src": "javascript:alert(1)"}, + id="image-src", + ), + pytest.param( + '[x]{href="vbscript:x"}', + "

x

\n", + {"href": "vbscript:x"}, + id="span-href", + ), + pytest.param( + '{formaction="javascript:x"}\npara\n', + "

para

\n", + {"formaction": "javascript:x"}, + id="block-formaction", + ), + pytest.param( + '{action="data:text/html,x"}\npara\n', + "

para

\n", + {"action": "data:text/html,x"}, + id="block-action", + ), + pytest.param( + '{xlink:href="javascript:x"}\npara\n', + "

para

\n", + {"xlink:href": "javascript:x"}, + id="block-xlink-href", + ), + ], +) +def test_url_attrs_validated(src, expected, removed): + """URL-valued attributes go through ``md.validateLink``, like link destinations. + + Otherwise ``{href="javascript:..."}`` would bypass the check markdown-it + applies to ``[a](javascript:...)``, even with ``html=False``. + """ + md = ( + MarkdownIt("commonmark", {"html": False}) + .use(attrs_plugin, spans=True) + .use(attrs_block_plugin) + ) + assert md.render(src) == expected + tokens = md.parse(src) + metas = [t.meta for t in tokens] + [ + c.meta for t in tokens for c in (t.children or []) + ] + assert {"insecure_attrs": removed} in metas + + +@pytest.mark.parametrize( + "src,expected", + [ + pytest.param( + '[a](https://x){href="https://ok"}', + '

a

\n', + id="link-https", + ), + pytest.param( + '[a](https://x){href="#frag"}', + '

a

\n', + id="link-fragment", + ), + pytest.param( + '![i](x.png){src="data:image/png;base64,AAA"}', + '

i

\n', + id="image-data-png", + ), + pytest.param( + '{href="https://ok"}\npara\n', + '

para

\n', + id="block-https", + ), + ], +) +def test_url_attrs_valid_kept(src, expected): + """URLs that ``md.validateLink`` accepts are kept as written.""" + md = ( + MarkdownIt("commonmark", {"html": False}) + .use(attrs_plugin, spans=True) + .use(attrs_block_plugin) + ) + assert md.render(src) == expected + + +def test_url_attrs_validated_when_allowed(): + """An explicit ``allowed`` list does not skip the URL check.""" + md = MarkdownIt("commonmark", {"html": False}).use(attrs_plugin, allowed=["href"]) + src = '[a](https://x){href="javascript:alert(1)"}' + assert md.render(src) == '

a

\n' + link_open = md.parse(src)[1].children[0] + assert link_open.meta == {"insecure_attrs": {"href": "javascript:alert(1)"}} + + +def test_url_attrs_use_custom_validate_link(): + """A ``validateLink`` override on the instance is honoured.""" + md = MarkdownIt("commonmark", {"html": False}).use(attrs_block_plugin) + md.validateLink = lambda url: not url.startswith("https://blocked") # type: ignore[method-assign] + assert md.render('{href="https://blocked/x"}\npara\n') == "

para

\n" + assert ( + md.render('{href="https://fine/x"}\npara\n') + == '

para

\n' + ) + + +@pytest.mark.parametrize( + "allowed,expected", + [ + pytest.param( + None, + [ + '

x

\n', + '

a

\n', + '

para

\n', + ], + id="default", + ), + pytest.param( + ("id", "class"), + [ + '

x

\n', + '

a

\n', + '

para

\n', + ], + id="allowed-class", + ), + pytest.param( + ("id",), + [ + "

x

\n", + '

a

\n', + "

para

\n", + ], + id="allowed-no-class", + ), + ], +) +def test_class_accumulation_is_filtered(allowed, expected): + """Classes accumulated across attribute groups still go through the filters. + + Inline groups after a closing token (``span_close``, ``link_close``) + are merged onto its opening token, appending to the class already there, + and stacked block attributes are merged before they reach the block. + Neither merge may carry an ``on*`` attribute, a rejected URL, + or (with ``allowed``) a disallowed name onto the token. + """ + md = ( + MarkdownIt("commonmark", {"html": False}) + .use(attrs_plugin, spans=True, allowed=allowed) + .use(attrs_block_plugin, allowed=allowed) + ) + sources = [ + "[x]{.a onclick=e}{.b style=s}", + '[a](https://x){.a}{.b href="javascript:alert(1)"}', + '{.a onclick=e}\n{.b href="javascript:x"}\npara\n', + ] + assert [md.render(src) for src in sources] == expected + + class _ScanCountingStr(str): """A string that counts the characters its scanning methods could visit. diff --git a/tests/test_dollarmath.py b/tests/test_dollarmath.py index 2f3a8e3..71c199e 100644 --- a/tests/test_dollarmath.py +++ b/tests/test_dollarmath.py @@ -131,3 +131,36 @@ def test_dollarmath_fixtures(line, title, input, expected): text = md.render(input) print(text) assert text.rstrip() == expected.rstrip() + + +# no parentheses inside the label, or it is not parsed as one +_LABEL_XSS = '$$\na=1\n$$ (">)\n' + + +@pytest.mark.parametrize( + "label_normalizer,label", + [ + pytest.param( + None, + ""><svg-onload=alert`1`>", + id="default-normalizer", + ), + pytest.param( + lambda label: label, + ""><svg onload=alert`1`>", + id="identity-normalizer", + ), + ], +) +def test_label_is_html_escaped(label_normalizer, label): + """The label must not break out of the ``id`` and ``href`` attributes (#143).""" + md = MarkdownIt("commonmark", {"html": False}).use( + dollarmath_plugin, label_normalizer=label_normalizer + ) + # the payload must reach the label renderer, not fall back to a paragraph + assert [t.type for t in md.parse(_LABEL_XSS)] == ["math_block_label"] + assert md.render(_LABEL_XSS) == ( + f'
\n' + f'' + "¶\na=1\n
\n" + ) diff --git a/tests/test_myst_block.py b/tests/test_myst_block.py index fd87cb7..fa1af7b 100644 --- a/tests/test_myst_block.py +++ b/tests/test_myst_block.py @@ -79,3 +79,11 @@ def test_comment_token(): ) expected_token.attrSet("class", "myst-line-comment") assert tokens == [expected_token] + + +def test_target_label_is_html_escaped(): + """MyST target labels must be HTML-escaped in href and text content (XSS).""" + md = MarkdownIt("commonmark").use(myst_block_plugin) + text = md.render("()=") + assert "`` for element and attribute contexts: a plugin that escapes turns + the ``<`` into ``<``, so the ``{_TAG_ESCAPED}", + ), + Case( + "dollarmath-label", + lambda md: md.use(dollarmath_plugin), + f"$$a=1$$ ({_TAG})\n", + _TAG_MARKER, + f'
', + ), + Case( + "dollarmath-label-quote", + lambda md: md.use(dollarmath_plugin), + f"$$a=1$$ ({_QUOTE})\n", + _QUOTE_MARKER, + '
', + ), + Case( + "myst-target", + lambda md: md.use(myst_block_plugin), + f"({_TAG})=\n", + _TAG_MARKER, + f'', + ), + Case( + "myst-target-quote", + lambda md: md.use(myst_block_plugin), + f"({_QUOTE})=\n", + _QUOTE_MARKER, + '', + ), + Case( + "colon-fence-name", + lambda md: md.use(colon_fence_plugin), + f":::a{_TAG}\nx\n:::\n", + _TAG_MARKER, + f'', + ), + Case( + "colon-fence-name-quote", + # the block name ends at the first space, so the probe has none + lambda md: md.use(colon_fence_plugin), + ':::a"onxss="1\nx\n:::\n', + _QUOTE_MARKER, + '', + ), + Case( + "tasklists-label-after", + lambda md: md.use(tasklists_plugin, label=True, label_after=True), + f"- [ ] {_TAG}\n", + _TAG_MARKER, + f" {_TAG_ESCAPED}", + ), + Case( + "attrs-span-event", + lambda md: md.use(attrs_plugin, spans=True), + '[click]{onxss="alert(1)"}\n', + "onxss=", + "

click

", + ), + Case( + "attrs-image-event", + lambda md: md.use(attrs_plugin), + '![a](img){onxss="alert(1)"}\n', + "onxss=", + '

a

', + ), + Case( + "attrs-block-event", + lambda md: md.use(attrs_block_plugin), + '{onxss="alert(1)"}\nparagraph\n', + "onxss=", + "

paragraph

", + ), + Case( + "attrs-link-href-url", + lambda md: md.use(attrs_plugin), + '[a](https://x){href="javascript:alert(1)"}\n', + "javascript:", + '

a

', + ), +] + + +def _md() -> MarkdownIt: + md = MarkdownIt("commonmark", {"html": False}) + md.options["xhtmlOut"] = False + return md + + +@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.id) +def test_plugin_escapes_injection(case: Case) -> None: + md = _md() + case.configure(md) + out = md.render(case.payload) + assert case.marker not in out, ( + f"{case.id} emitted {case.marker!r} unescaped: {out!r}" + ) + assert case.reached in out, f"{case.id} did not reach the renderer: {out!r}" + + +@pytest.mark.xfail( + strict=True, + reason="texmath never renders equation numbers: its render rules close over " + "the loop variable (# noqa: B023), so every block uses the last template", +) +def test_texmath_eqno_label_escaped() -> None: + """The texmath equation number is escaped when it is rendered. + + It is not rendered today, so this fails; when that bug is fixed it passes, + and ``strict`` turns the unexpected pass red so this marker is removed. + """ + md = _md().use(texmath_plugin, delimiters="gitlab") + src = f"```math x ``` ({_TAG})" + assert [t.type for t in md.parse(src)] == ["math_block_eqno"] + out = md.render(src) + assert _TAG_MARKER not in out + assert _TAG_ESCAPED in out