From 9957658b0b7d5789c292a1295aac35a5d202419b Mon Sep 17 00:00:00 2001 From: Naaz nuria Date: Thu, 22 Oct 2026 22:59:04 +0530 Subject: [PATCH] Fix quadratic-time backtracking in hash header regex `HashHeaderProcessor.RE` matches the header text lazily one character at a time and, after each step, tries `#*(?:\n|$)` to see whether only closing hashes remain. When the lazy match is inside a run of `#` that is followed by more text, that check scans to the end of the run and then fails, so a run of n hashes costs O(n^2). A line such as `# ` followed by 8000 `#` and an `x` takes about 13 seconds to convert. A run of hashes ending in a backslash (`'#' * n + '\\'`) hits the same path. Header text now consumes a run of hashes in one step when the run is followed by something other than a newline or the end of the block, and a lone `#` is no longer matched by the single-character branch. The lazy match therefore only stops at the start of a hash run, never inside one, and every match and capture group is unchanged. This was checked against the old pattern on every string of up to 8 characters over `#`, `\`, `a` and newline, plus random longer strings. This differs from #1545, where the time was spent in inline escape handling; here the time is spent inside the block regex itself. --- docs/changelog.md | 2 ++ markdown/blockprocessors.py | 2 +- tests/test_syntax/blocks/test_headers.py | 12 ++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 6f27060e..5dc7b0f8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -21,6 +21,8 @@ See the [Contributing Guide](contributing.md) for details. * 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 (#1656). +* Fix quadratic-time regex backtracking in hash headers when a long run of `#` + characters is followed by more text on the same line. * Fix regression in `BacktickInlineProcessor` where text could be lost when opening and closing backticks did not match (#1668). * Escape the language name in the `codehilite` extension's non-Pygments output to diff --git a/markdown/blockprocessors.py b/markdown/blockprocessors.py index cbad917b..728bf8f9 100644 --- a/markdown/blockprocessors.py +++ b/markdown/blockprocessors.py @@ -458,7 +458,7 @@ class HashHeaderProcessor(BlockProcessor): """ Process Hash Headers. """ # Detect a header at start of any line in block - RE = re.compile(r'(?:^|\n)(?P#{1,6})(?P
(?:\\.|[^\\])*?)#*(?:\n|$)') + RE = re.compile(r'(?:^|\n)(?P#{1,6})(?P
(?:\\.|[^\\#]|#+(?=[^#\n]))*?)#*(?:\n|$)') def test(self, parent: etree.Element, block: str) -> bool: return bool(self.RE.search(block)) diff --git a/tests/test_syntax/blocks/test_headers.py b/tests/test_syntax/blocks/test_headers.py index b1992ad3..60267215 100644 --- a/tests/test_syntax/blocks/test_headers.py +++ b/tests/test_syntax/blocks/test_headers.py @@ -779,3 +779,15 @@ def test_unescaped_hash(self): """ ) ) + + def test_long_run_of_hashes_not_at_end(self): + """ + A long run of hashes followed by more text on the same line is part of + the header text and should not be scanned once per hash when looking + for the closing hashes. + """ + hashes = '#' * 20000 + self.assertMarkdownRenders( + '# ' + hashes + 'x', + '

' + hashes + 'x

' + )