Skip to content

Commit 00bb6ec

Browse files
🔒 FIX: Escape plugin renderer output and harden attrs defaults (#150)
Fixes advisories 1, 2, 3 and 5 of #143, in default configurations: - texmath: the default render() HTML-escapes the math source. The equation-number label is escaped too, but equation numbers are not rendered yet (texmath B023, follow-up), so that escape is untested. - dollarmath: the equation label is escaped in the id and permalink href. - myst_block: the (name)= target label is escaped in the anchor. - attrs / attrs_block: with no `allowed` list, event-handler (on*) attributes are removed into token.meta["insecure_attrs"]; style is kept. Whatever `allowed` says, href, src, action, formaction and xlink:href are removed when md.validateLink rejects their value. Text spans now honour `allowed`, which they bypassed. tests/test_xss.py renders injection probes through the default renderers of texmath, dollarmath, myst_block, colon_fence, tasklists and attrs with html=False. Co-authored-by: Kyle King <KyleKing@users.noreply.github.com>
1 parent e76acea commit 00bb6ec

12 files changed

Lines changed: 604 additions & 25 deletions

File tree

‎mdit_py_plugins/attrs/index.py‎

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from collections.abc import Sequence
44
from functools import partial
5+
import re
56
from typing import Any
67

78
from markdown_it import MarkdownIt
@@ -62,10 +63,24 @@ def attrs_plugin(
6263
:param allowed: A list of allowed attribute names.
6364
If not ``None``, any attributes not in this list will be removed
6465
and placed in the token's meta under the key "insecure_attrs".
66+
If ``None`` (the default), event-handler attributes
67+
(any name starting with ``on``, such as ``onclick``) are still removed,
68+
as a baseline protection against script injection.
69+
Other attributes, including ``style``, are kept.
70+
Whatever ``allowed`` says, the URL-valued attributes
71+
``href``, ``src``, ``action``, ``formaction`` and ``xlink:href``
72+
are removed when ``md.validateLink`` rejects their value
73+
(for example a ``javascript:`` URL), as for Markdown links and images.
74+
This is not a full sanitiser: to safely render untrusted input,
75+
pass an explicit ``allowed`` list (e.g. ``("id", "class")``).
6576
"""
6677

6778
if spans:
68-
md.inline.ruler.after(span_after, "span", _span_rule)
79+
md.inline.ruler.after(
80+
span_after,
81+
"span",
82+
partial(_span_rule, allowed=None if allowed is None else set(allowed)),
83+
)
6984
if after:
7085
md.inline.ruler.push(
7186
"attr",
@@ -97,6 +112,16 @@ def attrs_block_plugin(md: MarkdownIt, *, allowed: Sequence[str] | None = None)
97112
:param allowed: A list of allowed attribute names.
98113
If not ``None``, any attributes not in this list will be removed
99114
and placed in the token's meta under the key "insecure_attrs".
115+
If ``None`` (the default), event-handler attributes
116+
(any name starting with ``on``, such as ``onclick``) are still removed,
117+
as a baseline protection against script injection.
118+
Other attributes, including ``style``, are kept.
119+
Whatever ``allowed`` says, the URL-valued attributes
120+
``href``, ``src``, ``action``, ``formaction`` and ``xlink:href``
121+
are removed when ``md.validateLink`` rejects their value
122+
(for example a ``javascript:`` URL), as for Markdown links and images.
123+
This is not a full sanitiser: to safely render untrusted input,
124+
pass an explicit ``allowed`` list (e.g. ``("id", "class")``).
100125
"""
101126
md.block.ruler.before("fence", "attr", _attr_block_rule)
102127
md.core.ruler.after(
@@ -121,7 +146,9 @@ def _find_opening(tokens: Sequence[Token], index: int) -> int | None:
121146
return None
122147

123148

124-
def _span_rule(state: StateInline, silent: bool) -> bool:
149+
def _span_rule(
150+
state: StateInline, silent: bool, *, allowed: set[str] | None = None
151+
) -> bool:
125152
if state.src[state.pos] != "[":
126153
return False
127154

@@ -150,7 +177,7 @@ def _span_rule(state: StateInline, silent: bool) -> bool:
150177
state.pos = labelStart
151178
state.posMax = labelEnd
152179
token = state.push("span_open", "span", 1)
153-
token.attrs = attrs # type: ignore[assignment]
180+
_add_attrs(state.md, token, attrs, allowed)
154181
state.md.inline.tokenize(state)
155182
token = state.push("span_close", "span", -1)
156183

@@ -183,7 +210,7 @@ def _attr_inline_rule(
183210
attr_token = state.tokens[token_index]
184211
if "class" in attrs and "class" in attr_token.attrs:
185212
attrs["class"] = f"{attr_token.attrs['class']} {attrs['class']}"
186-
_add_attrs(attr_token, attrs, allowed)
213+
_add_attrs(state.md, attr_token, attrs, allowed)
187214
return True
188215

189216

@@ -260,23 +287,69 @@ def _attr_resolve_block_rule(state: StateCore, *, allowed: set[str] | None) -> N
260287
if key == "class" or key not in next_token.attrs:
261288
next_token.attrs[key] = value
262289
else:
263-
_add_attrs(next_token, state.tokens[i].attrs, allowed)
290+
_add_attrs(state.md, next_token, state.tokens[i].attrs, allowed)
264291

265292
state.tokens.pop(i)
266293
len_tokens -= 1
267294

268295

296+
_URL_ATTRS = frozenset(("href", "src", "action", "formaction", "xlink:href"))
297+
"""Attributes whose value is a URL, checked with ``md.validateLink``."""
298+
299+
_URL_IGNORED_RE = re.compile(r"[\t\n\r]")
300+
_URL_STRIP = "".join(chr(i) for i in range(0x21))
301+
302+
303+
def _is_insecure_attr(key: str) -> bool:
304+
"""Return True for event-handler attributes (``on*``, e.g. ``onclick``).
305+
306+
They execute JavaScript, so they are removed by default
307+
when no explicit ``allowed`` list is given.
308+
"""
309+
return key.lower().startswith("on")
310+
311+
312+
def _is_valid_url(md: MarkdownIt, value: Any) -> bool:
313+
"""Return True if ``md.validateLink`` accepts a URL-valued attribute.
314+
315+
Browsers ignore ASCII tabs and newlines anywhere in a URL,
316+
and control characters and spaces around it, before reading its scheme
317+
(so ``java<TAB>script:`` is a ``javascript:`` URL).
318+
The value is emitted as written, so those characters are removed
319+
before it is normalised and validated.
320+
"""
321+
url = _URL_IGNORED_RE.sub("", str(value)).strip(_URL_STRIP)
322+
return md.validateLink(md.normalizeLink(url))
323+
324+
269325
def _add_attrs(
326+
md: MarkdownIt,
270327
token: Token,
271328
attrs: dict[str, Any],
272329
allowed: set[str] | None,
273330
) -> None:
274-
"""Add attributes to a token, skipping any disallowed attributes."""
275-
if allowed is not None and (
276-
disallowed := {k: v for k, v in attrs.items() if k not in allowed}
277-
):
331+
"""Add attributes to a token, skipping any disallowed attributes.
332+
333+
When ``allowed`` is not ``None`` only those names are kept.
334+
When it is ``None`` (the default) event-handler (``on*``) attributes
335+
are still removed, so untrusted input cannot inject script.
336+
In both cases, a URL-valued attribute (``href``, ``src``, ...)
337+
is removed if ``md.validateLink`` rejects it.
338+
Anything removed is preserved in ``token.meta["insecure_attrs"]``.
339+
"""
340+
if allowed is not None:
341+
disallowed = {k: v for k, v in attrs.items() if k not in allowed}
342+
else:
343+
disallowed = {k: v for k, v in attrs.items() if _is_insecure_attr(k)}
344+
disallowed.update(
345+
(k, v)
346+
for k, v in attrs.items()
347+
if k not in disallowed and k.lower() in _URL_ATTRS and not _is_valid_url(md, v)
348+
)
349+
350+
if disallowed:
278351
token.meta["insecure_attrs"] = disallowed
279-
attrs = {k: v for k, v in attrs.items() if k in allowed}
352+
attrs = {k: v for k, v in attrs.items() if k not in disallowed}
280353

281354
# attributes takes precedence over existing attributes
282355
token.attrs.update(attrs)

‎mdit_py_plugins/dollarmath/index.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def dollarmath_plugin(
9898
if label_renderer is None:
9999
_label_renderer = ( # noqa: E731
100100
lambda label: (
101-
f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>'
101+
f'<a href="#{escapeHtml(label)}" class="mathlabel" title="Permalink to this equation">¶</a>'
102102
)
103103
)
104104
else:
@@ -142,7 +142,7 @@ def render_math_block_label(
142142
env: EnvType,
143143
) -> str:
144144
content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True})
145-
_id = tokens[idx].info
145+
_id = escapeHtml(tokens[idx].info)
146146
label = _label_renderer(tokens[idx].info)
147147
return f'<div id="{_id}" class="math block">\n{label}\n{content}\n</div>\n'
148148

‎mdit_py_plugins/myst_blocks/index.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def render_myst_target(
152152
options: OptionsDict,
153153
env: EnvType,
154154
) -> str:
155-
label = tokens[idx].content
155+
label = escapeHtml(tokens[idx].content)
156156
class_name = "myst-target"
157157
target = f'<a href="#{label}">({label})=</a>'
158158
return f'<div class="{class_name}">{target}</div>'

‎mdit_py_plugins/texmath/index.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import TYPE_CHECKING, Any, TypedDict
77

88
from markdown_it import MarkdownIt
9-
from markdown_it.common.utils import charCodeAt
9+
from markdown_it.common.utils import charCodeAt, escapeHtml
1010

1111
if TYPE_CHECKING:
1212
from markdown_it.renderer import RendererProtocol
@@ -65,7 +65,8 @@ def render_math_block(
6565
env: EnvType,
6666
) -> str:
6767
return rule_block["tmpl"].format( # noqa: B023
68-
render(tokens[idx].content, True, macros), tokens[idx].info
68+
render(tokens[idx].content, True, macros),
69+
escapeHtml(tokens[idx].info),
6970
)
7071

7172
md.add_render_rule(rule_block["name"], render_math_block)
@@ -172,7 +173,7 @@ def dollar_post(src: str, end: int) -> bool:
172173

173174

174175
def render(tex: str, displayMode: bool, macros: Any) -> str:
175-
return tex
176+
return escapeHtml(tex)
176177
# TODO better HTML renderer port for math
177178
# try:
178179
# res = katex.renderToString(tex,{throwOnError:False,displayMode,macros})

‎tests/fixtures/myst_block.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Target characters:
5252
.
5353
(a bc |@<>*./_-+:)=
5454
.
55-
<div class="myst-target"><a href="#a bc |@<>*./_-+:">(a bc |@<>*./_-+:)=</a></div>
55+
<div class="myst-target"><a href="#a bc |@&lt;&gt;*./_-+:">(a bc |@&lt;&gt;*./_-+:)=</a></div>
5656
.
5757

5858
Empty target:

‎tests/fixtures/texmath_bracket.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ simple equation including special html character. (valid=True)
2323
.
2424
\(1+1<3\)
2525
.
26-
<p><eq>1+1<3</eq></p>
26+
<p><eq>1+1&lt;3</eq></p>
2727
.
2828

2929
equation including backslashes. (valid=True)
@@ -239,8 +239,8 @@ multiline equation. (valid=True)
239239
.
240240
<section>
241241
<eqn>\\begin{matrix}
242-
f & = & 2 + x + 3 \\
243-
& = & 5 + x
242+
f &amp; = &amp; 2 + x + 3 \\
243+
&amp; = &amp; 5 + x
244244
\\end{matrix}</eqn>
245245
</section>
246246
.
@@ -253,7 +253,7 @@ vector equation. (valid=True)
253253
.
254254
<section>
255255
<eqn>\\begin{pmatrix}x_2 \\\\ y_2 \\end{pmatrix} =
256-
\\begin{pmatrix} A & B \\\\ C & D \\end{pmatrix}\\cdot
256+
\\begin{pmatrix} A &amp; B \\\\ C &amp; D \\end{pmatrix}\\cdot
257257
\\begin{pmatrix} x_1 \\\\ y_1 \\end{pmatrix}</eqn>
258258
</section>
259259
.

‎tests/fixtures/texmath_dollar.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ simple equation including special html character. (valid=True)
2323
.
2424
$1+1<3$
2525
.
26-
<p><eq>1+1<3</eq></p>
26+
<p><eq>1+1&lt;3</eq></p>
2727
.
2828

2929
equation including backslashes. (valid=True)
@@ -239,8 +239,8 @@ $$\\begin{matrix}
239239
.
240240
<section>
241241
<eqn>\\begin{matrix}
242-
f & = & 2 + x + 3 \\
243-
& = & 5 + x
242+
f &amp; = &amp; 2 + x + 3 \\
243+
&amp; = &amp; 5 + x
244244
\\end{matrix}</eqn>
245245
</section>
246246
.
@@ -253,7 +253,7 @@ $$\\begin{pmatrix}x_2 \\\\ y_2 \\end{pmatrix} =
253253
.
254254
<section>
255255
<eqn>\\begin{pmatrix}x_2 \\\\ y_2 \\end{pmatrix} =
256-
\\begin{pmatrix} A & B \\\\ C & D \\end{pmatrix}\\cdot
256+
\\begin{pmatrix} A &amp; B \\\\ C &amp; D \\end{pmatrix}\\cdot
257257
\\begin{pmatrix} x_1 \\\\ y_1 \\end{pmatrix}</eqn>
258258
</section>
259259
.

0 commit comments

Comments
 (0)