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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
- `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.

A categorical guard (`tests/test_xss.py`) renders injection probes through each plugin's default renderer, so a future unescaped interpolation is caught before review.

## 0.7.0 - 2026-07-19

- ✨ NEW: Add section reference plugin (`section_ref`) (#144)
Expand Down
6 changes: 3 additions & 3 deletions mdit_py_plugins/texmath/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def render_math_inline(
env: EnvType,
) -> str:
return rule_inline["tmpl"].format( # noqa: B023
escapeHtml(render(tokens[idx].content, False, macros))
render(tokens[idx].content, False, macros)
)

md.add_render_rule(rule_inline["name"], render_math_inline)
Expand All @@ -65,7 +65,7 @@ def render_math_block(
env: EnvType,
) -> str:
return rule_block["tmpl"].format( # noqa: B023
escapeHtml(render(tokens[idx].content, True, macros)),
render(tokens[idx].content, True, macros),
escapeHtml(tokens[idx].info),
)

Expand Down Expand Up @@ -170,7 +170,7 @@ def dollar_post(src: str, end: int) -> bool:


def render(tex: str, displayMode: bool, macros: Any) -> str:
return tex
return escapeHtml(tex)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the rendering logic below is implemented, we want the escaping to be owned here because doing so around render would break the valid HTML and reasonable to be owned by render

# TODO better HTML renderer port for math
# try:
# res = katex.renderToString(tex,{throwOnError:False,displayMode,macros})
Expand Down
108 changes: 108 additions & 0 deletions tests/test_xss.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Categorical XSS guard: render injection probes through each plugin's defaults.

Several plugins register default HTML render rules that interpolate
attacker-controlled token content into markup. Rather than trust review to catch
the next unescaped interpolation, this drives each plugin end-to-end with a probe
and asserts the raw marker never reaches the output (``markdown-it`` runs with raw
HTML disabled, so a correctly-escaped plugin emits ``<xss>`` instead).

Two probe shapes cover the two sinks:

- ``"><xss>`` for HTML/attribute contexts: a plugin that escapes turns the ``<``
into ``&lt;`` so the ``<xss`` marker disappears; one that doesn't leaks a live
element (or breaks out of an attribute).
- ``on*`` / ``style`` attribute keys for ``attrs``, where the injection is the
attribute itself: the default config diverts these to ``token.meta`` so the
marker is absent from output.

The only per-plugin cost is one activation payload placing the probe where the
plugin captures token content.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass

from markdown_it import MarkdownIt
import pytest

from mdit_py_plugins.attrs import attrs_block_plugin, attrs_plugin
from mdit_py_plugins.dollarmath import dollarmath_plugin
from mdit_py_plugins.myst_blocks import myst_block_plugin
from mdit_py_plugins.texmath import texmath_plugin

# A live-element / attribute-breakout probe and the marker that only survives in
# the output if the plugin emitted it without HTML-escaping.
_TAG = '"><xss>'
_TAG_MARKER = "<xss"


@dataclass(frozen=True)
class Case:
id: str
configure: Callable[[MarkdownIt], None]
payload: str
marker: str


_CASES = [
Case(
"texmath-inline-content",
lambda md: md.use(texmath_plugin),
f"a ${_TAG}$ b",
_TAG_MARKER,
),
Case(
"texmath-block-eqno-info",
lambda md: md.use(texmath_plugin, delimiters="gitlab"),
"```math x ``` (<xss onx=1>)",
"<xss",
),
Case(
"dollarmath-label",
lambda md: md.use(dollarmath_plugin),
f"$$a=1$$ ({_TAG})\n",
_TAG_MARKER,
),
Case(
"myst-target",
lambda md: md.use(myst_block_plugin),
f"({_TAG})=\n",
_TAG_MARKER,
),
Case(
"attrs-span-event",
lambda md: md.use(attrs_plugin, spans=True),
'[click]{onxss="alert(1)"}\n',
"onxss=",
),
Case(
"attrs-image-event",
lambda md: md.use(attrs_plugin),
'![a](img){onxss="alert(1)"}\n',
"onxss=",
),
Case(
"attrs-span-style",
lambda md: md.use(attrs_plugin, spans=True),
'[click]{style="color:red"}\n',
"style=",
),
Case(
"attrs-block-event",
lambda md: md.use(attrs_block_plugin),
'{onxss="alert(1)"}\nparagraph\n',
"onxss=",
),
]


@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.id)
def test_plugin_escapes_injection(case: Case) -> None:
md = MarkdownIt("commonmark")
case.configure(md)
out = md.render(case.payload)
assert case.marker not in out, (
f"{case.id} emitted {case.marker!r} unescaped: {out!r}"
)
Loading