diff --git a/docs/changelog.md b/docs/changelog.md index 5e6fc0117..b7a50f0c7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -16,6 +16,8 @@ See the [Contributing Guide](contributing.md) for details. ### Fixed +* Sanitize attribute names in the `fenced_code` and `legacy_attrs` extensions so an + attribute name cannot break out of its tag and inject markup (#1663). * For backwards compatibility in some languages, revert the enforcement of CommonMark style punctuation rules (#1657). diff --git a/markdown/extensions/attr_list.py b/markdown/extensions/attr_list.py index 518288087..c1643dd1f 100644 --- a/markdown/extensions/attr_list.py +++ b/markdown/extensions/attr_list.py @@ -88,17 +88,28 @@ def isheader(elem: Element) -> bool: return elem.tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] +NAME_RE = re.compile(r'[^A-Z_a-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02ff' + r'\u0370-\u037d\u037f-\u1fff\u200c-\u200d' + r'\u2070-\u218f\u2c00-\u2fef\u3001-\ud7ff' + r'\uf900-\ufdcf\ufdf0-\ufffd' + r'\:\-\.0-9\u00b7\u0300-\u036f\u203f-\u2040]+') + + +def sanitize_name(name: str) -> str: + """ + Sanitize name as 'an XML Name, minus the `:`.' + See . + """ + return NAME_RE.sub('_', name) + + class AttrListTreeprocessor(Treeprocessor): BASE_RE = r'\{\:?[ ]*([^\}\n ][^\n]*)[ ]*\}' HEADER_RE = re.compile(r'[ ]+{}[ ]*$'.format(BASE_RE)) BLOCK_RE = re.compile(r'\n[ ]*{}[ ]*$'.format(BASE_RE)) INLINE_RE = re.compile(r'^{}'.format(BASE_RE)) - NAME_RE = re.compile(r'[^A-Z_a-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02ff' - r'\u0370-\u037d\u037f-\u1fff\u200c-\u200d' - r'\u2070-\u218f\u2c00-\u2fef\u3001-\ud7ff' - r'\uf900-\ufdcf\ufdf0-\ufffd' - r'\:\-\.0-9\u00b7\u0300-\u036f\u203f-\u2040]+') + NAME_RE = NAME_RE def run(self, doc: Element) -> None: for elem in doc.iter(): @@ -190,7 +201,7 @@ def sanitize_name(self, name: str) -> str: Sanitize name as 'an XML Name, minus the `:`.' See . """ - return self.NAME_RE.sub('_', name) + return sanitize_name(name) class AttrListExtension(Extension): diff --git a/markdown/extensions/fenced_code.py b/markdown/extensions/fenced_code.py index 603f0bc7b..fbdd015bd 100644 --- a/markdown/extensions/fenced_code.py +++ b/markdown/extensions/fenced_code.py @@ -25,7 +25,7 @@ from . import Extension from ..preprocessors import Preprocessor from .codehilite import CodeHilite, CodeHiliteExtension, parse_hl_lines -from .attr_list import get_attrs_and_remainder, AttrListExtension +from .attr_list import get_attrs_and_remainder, sanitize_name, AttrListExtension from ..util import parseBoolValue from ..serializers import _escape_attrib_html import re @@ -157,7 +157,8 @@ def run(self, lines: list[str]) -> list[str]: # pairs were defined on the code block, and the `use_pygments` key was not set to `True`. The # `use_pygments` key could be either set to `False` or not defined. It is omitted from output. kv_pairs = ''.join( - f' {k}="{_escape_attrib_html(v)}"' for k, v in config.items() if k != 'use_pygments' + f' {sanitize_name(k)}="{_escape_attrib_html(v)}"' + for k, v in config.items() if k != 'use_pygments' ) code = self._escape(m.group('code')) code = f'{code}' diff --git a/markdown/extensions/legacy_attrs.py b/markdown/extensions/legacy_attrs.py index 44aaf99ae..4b82c0efd 100644 --- a/markdown/extensions/legacy_attrs.py +++ b/markdown/extensions/legacy_attrs.py @@ -33,6 +33,7 @@ import re from markdown.treeprocessors import Treeprocessor, isString from markdown.extensions import Extension +from markdown.extensions.attr_list import sanitize_name from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover @@ -57,7 +58,7 @@ def run(self, doc: etree.Element) -> None: def handleAttributes(self, el: etree.Element, txt: str) -> str: """ Set attributes and return text without definitions. """ def attributeCallback(match: re.Match[str]): - el.set(match.group(1), match.group(2).replace('\n', ' ')) + el.set(sanitize_name(match.group(1)), match.group(2).replace('\n', ' ')) return ATTR_RE.sub(attributeCallback, txt) diff --git a/tests/test_syntax/extensions/test_fenced_code.py b/tests/test_syntax/extensions/test_fenced_code.py index aa58bbfb4..cbe101db3 100644 --- a/tests/test_syntax/extensions/test_fenced_code.py +++ b/tests/test_syntax/extensions/test_fenced_code.py @@ -304,6 +304,25 @@ def testFencedLanguageNoCodehiliteWithAttrList(self): extensions=['fenced_code', 'attr_list'] ) + def testFencedCodeAttrNameSanitizedNoCodehiliteWithAttrList(self): + # An attribute name is sanitized so it cannot break out of the `code` tag (see #1663). + self.assertMarkdownRenders( + self.dedent( + ''' + ``` { .python x># Some python code + + ''' + ), + extensions=['fenced_code', 'attr_list'] + ) + def testFencedLanguagePygmentsDisabledInAttrNoCodehiliteWithAttrList(self): self.assertMarkdownRenders( self.dedent( diff --git a/tests/test_syntax/extensions/test_legacy_attrs.py b/tests/test_syntax/extensions/test_legacy_attrs.py index 44e8a6159..c261d932f 100644 --- a/tests/test_syntax/extensions/test_legacy_attrs.py +++ b/tests/test_syntax/extensions/test_legacy_attrs.py @@ -65,3 +65,11 @@ def testLegacyAttrs(self): """), # noqa: E501 extensions=['legacy_attrs'] ) + + def testLegacyAttrsSanitizesName(self): + # An attribute name is sanitized so it cannot break out of the element (see #1663). + self.assertMarkdownRenders( + 'text {@x>text more

', + extensions=['legacy_attrs'] + )