22
33from collections .abc import Sequence
44from functools import partial
5+ import re
56from typing import Any
67
78from 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+
269325def _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 )
0 commit comments