From 09d3a7f0810ba826ab2c7e02333ec6b3632b7c05 Mon Sep 17 00:00:00 2001 From: Naaz nuria Date: Wed, 14 Oct 2026 19:36:59 +0530 Subject: [PATCH] Fix quadratic-time backtracking in attr_list attribute regex `AttrListTreeprocessor.BASE_RE` matched the attribute content with `([^\}\n ][^\n]*)` and then allowed trailing spaces before the closing brace with a separate `[ ]*`. Because `[^\n]*` already matches spaces, the two groups both cover the same run of trailing spaces. When a block ends with an unclosed attribute list, the closing brace is never found, so for a run of n spaces the engine retries the match from each of the n+1 ways to split the spaces between the two groups, which is O(n^2). The trailing `[ ]*` can only ever match zero characters in a successful match (the greedy `[^\n]*` consumes the spaces first), so dropping it leaves every match and capture group unchanged while removing the ambiguity. A two-line block such as `text\n{a` followed by 200k spaces drops from ~10s to ~20ms, and the cost is now linear in the input size. --- docs/changelog.md | 8 ++++++++ markdown/extensions/attr_list.py | 2 +- tests/test_syntax/extensions/test_attr_list.py | 12 ++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 4f5c00729..1e2f9dbd8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,14 @@ and this project adheres to the [Python Version Specification](https://packaging.python.org/en/latest/specifications/version-specifiers/). See the [Contributing Guide](contributing.md) for details. +## [Unreleased] + +### Fixed + +* Fix quadratic-time regex backtracking in `attr_list` when a block ends with an + unclosed attribute list followed by a long run of spaces, e.g. a line + consisting only of `{` and some text with many trailing spaces. + ## [3.11.1] - 2026-10-13 ### Fixed diff --git a/markdown/extensions/attr_list.py b/markdown/extensions/attr_list.py index 518288087..f1edc5798 100644 --- a/markdown/extensions/attr_list.py +++ b/markdown/extensions/attr_list.py @@ -90,7 +90,7 @@ def isheader(elem: Element) -> bool: class AttrListTreeprocessor(Treeprocessor): - BASE_RE = r'\{\:?[ ]*([^\}\n ][^\n]*)[ ]*\}' + 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)) diff --git a/tests/test_syntax/extensions/test_attr_list.py b/tests/test_syntax/extensions/test_attr_list.py index 1a0ae12c2..7558d0503 100644 --- a/tests/test_syntax/extensions/test_attr_list.py +++ b/tests/test_syntax/extensions/test_attr_list.py @@ -78,6 +78,18 @@ def test_backslash_escape_value(self): '

*Foo*

' ) + def test_unclosed_curly_with_long_run_of_spaces(self): + """ + An unclosed attribute list followed by a long run of spaces should be + left as plain text, no matter how many trailing spaces there are. + + The content and the trailing spaces both match the same part of the + pattern, so without care the regex backtracks quadratically over the + spaces while failing to find the closing brace. + """ + text = 'foo\n{a' + (' ' * 50000) + self.assertMarkdownRenders(text, f'

{text}

') + def test_table_td(self): self.assertMarkdownRenders( self.dedent(