Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Change Log

## Unreleased

- 🔒 SECURITY: Escape user-controlled content in plugin renderers and harden the `attrs` default (#143)

Addresses externally-reported XSS advisories affecting default configurations:

- `texmath`: math content and equation-number labels are now HTML-escaped by the default renderer, matching `dollarmath` and `amsmath`. Previously input such as ``$<img src=x onerror=alert(1)>$`` emitted live markup.
- `dollarmath`: equation labels are now escaped in the generated `id` attribute and permalink `href`.
- `myst_block`: target labels (``(name)=``) are now escaped in the rendered anchor.
- `attrs` / `attrs_block`: when no explicit `allowed` list is given, event-handler (`on*`) and `style` attributes are now stripped by default (removed entries are preserved in `token.meta["insecure_attrs"]`). Text spans (``[text]{...}``) now also honour the `allowed` list, which they previously bypassed entirely. This is a baseline protection, not a full sanitiser — pass an explicit `allowed` list (e.g. `("id", "class")`) to safely render untrusted input.

## 0.7.0 - 2026-07-19

- ✨ NEW: Add section reference plugin (`section_ref`) (#144)
Expand Down
53 changes: 45 additions & 8 deletions mdit_py_plugins/attrs/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,19 @@ 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 (``on*``) and ``style``
attributes are still removed as a baseline protection against script
and CSS injection. 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",
Expand Down Expand Up @@ -91,6 +100,11 @@ 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 (``on*``) and ``style``
attributes are still removed as a baseline protection against script
and CSS injection. 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(
Expand All @@ -115,7 +129,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

Expand Down Expand Up @@ -144,7 +160,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(token, attrs, allowed)
state.md.inline.tokenize(state)
token = state.push("span_close", "span", -1)

Expand Down Expand Up @@ -259,17 +275,38 @@ def _attr_resolve_block_rule(state: StateCore, *, allowed: set[str] | None) -> N
len_tokens -= 1


def _is_insecure_attr(key: str) -> bool:
"""Return True for attributes that enable scripting or style injection.

Event-handler attributes (``on*``, e.g. ``onclick``) execute JavaScript, and
``style`` permits CSS-based injection, so both are stripped by default when
no explicit ``allowed`` list is given.
"""
key = key.lower()
return key == "style" or key.startswith("on")


def _add_attrs(
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) an insecure-by-default baseline still removes
event-handler (``on*``) and ``style`` attributes, so untrusted input cannot
inject script or CSS. 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)}

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)
4 changes: 2 additions & 2 deletions mdit_py_plugins/dollarmath/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def dollarmath_plugin(
if label_renderer is None:
_label_renderer = ( # noqa: E731
lambda label: (
f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>'
f'<a href="#{escapeHtml(label)}" class="mathlabel" title="Permalink to this equation">¶</a>'
)
)
else:
Expand Down Expand Up @@ -123,7 +123,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'<div id="{_id}" class="math block">\n{label}\n{content}\n</div>\n'

Expand Down
2 changes: 1 addition & 1 deletion mdit_py_plugins/myst_blocks/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<a href="#{label}">({label})=</a>'
return f'<div class="{class_name}">{target}</div>'
Expand Down
7 changes: 4 additions & 3 deletions mdit_py_plugins/texmath/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,7 +47,7 @@ def render_math_inline(
env: EnvType,
) -> str:
return rule_inline["tmpl"].format( # noqa: B023
render(tokens[idx].content, False, macros)
escapeHtml(render(tokens[idx].content, False, macros))
)

md.add_render_rule(rule_inline["name"], render_math_inline)
Expand All @@ -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
escapeHtml(render(tokens[idx].content, True, macros)),
escapeHtml(tokens[idx].info),
)

md.add_render_rule(rule_block["name"], render_math_block)
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/myst_block.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Target characters:
.
(a bc |@<>*./_-+:)=
.
<div class="myst-target"><a href="#a bc |@<>*./_-+:">(a bc |@<>*./_-+:)=</a></div>
<div class="myst-target"><a href="#a bc |@&lt;&gt;*./_-+:">(a bc |@&lt;&gt;*./_-+:)=</a></div>
.

Empty target:
Expand Down
8 changes: 4 additions & 4 deletions tests/fixtures/texmath_bracket.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ simple equation including special html character. (valid=True)
.
\(1+1<3\)
.
<p><eq>1+1<3</eq></p>
<p><eq>1+1&lt;3</eq></p>
.

equation including backslashes. (valid=True)
Expand Down Expand Up @@ -239,8 +239,8 @@ multiline equation. (valid=True)
.
<section>
<eqn>\\begin{matrix}
f & = & 2 + x + 3 \\
& = & 5 + x
f &amp; = &amp; 2 + x + 3 \\
&amp; = &amp; 5 + x
\\end{matrix}</eqn>
</section>
.
Expand All @@ -253,7 +253,7 @@ vector equation. (valid=True)
.
<section>
<eqn>\\begin{pmatrix}x_2 \\\\ y_2 \\end{pmatrix} =
\\begin{pmatrix} A & B \\\\ C & D \\end{pmatrix}\\cdot
\\begin{pmatrix} A &amp; B \\\\ C &amp; D \\end{pmatrix}\\cdot
\\begin{pmatrix} x_1 \\\\ y_1 \\end{pmatrix}</eqn>
</section>
.
Expand Down
8 changes: 4 additions & 4 deletions tests/fixtures/texmath_dollar.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ simple equation including special html character. (valid=True)
.
$1+1<3$
.
<p><eq>1+1<3</eq></p>
<p><eq>1+1&lt;3</eq></p>
.

equation including backslashes. (valid=True)
Expand Down Expand Up @@ -239,8 +239,8 @@ $$\\begin{matrix}
.
<section>
<eqn>\\begin{matrix}
f & = & 2 + x + 3 \\
& = & 5 + x
f &amp; = &amp; 2 + x + 3 \\
&amp; = &amp; 5 + x
\\end{matrix}</eqn>
</section>
.
Expand All @@ -253,7 +253,7 @@ $$\\begin{pmatrix}x_2 \\\\ y_2 \\end{pmatrix} =
.
<section>
<eqn>\\begin{pmatrix}x_2 \\\\ y_2 \\end{pmatrix} =
\\begin{pmatrix} A & B \\\\ C & D \\end{pmatrix}\\cdot
\\begin{pmatrix} A &amp; B \\\\ C &amp; D \\end{pmatrix}\\cdot
\\begin{pmatrix} x_1 \\\\ y_1 \\end{pmatrix}</eqn>
</section>
.
Expand Down
40 changes: 40 additions & 0 deletions tests/test_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,43 @@ def test_attrs_allowed(data_regression):
`inline`{safe=a danger=b}
""")
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
('[x]{style="color:red"}', "style"),
('![a](i.png){onerror="alert(1)"}', "onerror"),
],
)
def test_event_handler_and_style_stripped_by_default(src, leaked):
"""With no ``allowed`` list, on* and style 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_benign_attrs_preserved_by_default():
"""The insecure-by-default baseline only strips on*/style, 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
19 changes: 19 additions & 0 deletions tests/test_dollarmath.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,22 @@ def test_dollarmath_fixtures(line, title, input, expected):
text = md.render(input)
print(text)
assert text.rstrip() == expected.rstrip()


def test_label_is_html_escaped():
"""Equation labels must be HTML-escaped in the id attribute and href (XSS)."""
md = MarkdownIt("commonmark").use(dollarmath_plugin)
text = md.render('$$a=1$$ ("><svg/onload=alert(1)>)')
# the payload must not break out of the attribute / inject an element
assert "<svg" not in text
assert '"><svg' not in text
assert "&lt;svg/onload=alert(1)&gt;" in text


def test_label_escaped_with_custom_normalizer():
"""Even a permissive label_normalizer output is escaped before rendering."""
md = MarkdownIt("commonmark").use(
dollarmath_plugin, label_normalizer=lambda label: label
)
text = md.render('$$a=1$$ ("><svg/onload=alert(1)>)')
assert "<svg" not in text
8 changes: 8 additions & 0 deletions tests/test_myst_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("(<img src=x onerror=alert(1)>)=")
assert "<img" not in text
assert "&lt;img src=x onerror=alert(1)&gt;" in text
16 changes: 16 additions & 0 deletions tests/test_texmath.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,19 @@ def test_bracket_fixtures(line, title, input, expected):
text = md.render(input)
print(text)
assert text.rstrip() == expected.rstrip()


@pytest.mark.parametrize("delimiters", ["dollars", "brackets", "gitlab", "julia"])
def test_math_content_is_html_escaped(delimiters):
"""Math content must be HTML-escaped so it cannot inject markup (XSS)."""
md = MarkdownIt("commonmark").use(texmath_plugin, delimiters=delimiters)
open_delim = {"dollars": "$", "brackets": r"\(", "gitlab": "$`", "julia": "``"}[
delimiters
]
close_delim = {"dollars": "$", "brackets": r"\)", "gitlab": "`$", "julia": "``"}[
delimiters
]
payload = "<img src=x onerror=alert(1)>"
text = md.render(f"a {open_delim}{payload}{close_delim} b")
assert "<img" not in text
assert "&lt;img src=x onerror=alert(1)&gt;" in text
Loading