From 29b0d331a13aacf84f3aa0cfee6f8542c980a951 Mon Sep 17 00:00:00 2001 From: stv <15886611803@qq.com> Date: Sat, 31 Oct 2026 13:08:28 +0800 Subject: [PATCH 1/3] Stop list nesting short of the recursion limit A list nests one level per `parseBlocks` call, so a deeply nested list -- 500 markers on one line is about 1 KB of input -- reached the interpreter's limit and raised `RecursionError` out of `markdown.markdown()`. Anything rendering untrusted Markdown could be crashed by it. `BlockQuoteProcessor` has been limited this way since #799: its `test()` stops nesting once `util.nearing_recursion_limit()` is true, and the remaining markers are kept as text. The list processors had no such guard, so `ListIndentProcessor.test()` and `OListProcessor.test()` now apply it, `UListProcessor` inheriting the latter. Content is not lost, the markers which no longer nest are rendered as text. The guard alone left the degraded path slow, because `get_items()` appended every continuation line with `'{}\n{}'.format(item, line)`, copying the whole item each time and making it quadratic in the number of lines. It now collects the lines of an item in a list and joins them once, which renders a 1200 level list in 3.8s instead of 20s. Output is unchanged on a corpus of 3000 random list documents. fixes #1679 --- docs/changelog.md | 5 ++ markdown/blockprocessors.py | 25 ++++-- tests/test_syntax/blocks/test_list_nesting.py | 82 +++++++++++++++++++ 3 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 tests/test_syntax/blocks/test_list_nesting.py diff --git a/docs/changelog.md b/docs/changelog.md index f3335f1e..a224ad14 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -41,6 +41,11 @@ See the [Contributing Guide](contributing.md) for details. provided, rather than rendering them as `key="key"`, so the same source renders consistently under both the `html` and `xhtml` output formats (#1501). +* Stop list nesting short of the interpreter's recursion limit, as blockquote + nesting already is, instead of raising `RecursionError` on a deeply nested + list. The markers which no longer nest are kept as text (#1679). +* Fix quadratic-time splitting of a list block whose items span many lines, + which dominated the time taken to render a deeply nested list. ## [3.11.2] - 2026-10-19 diff --git a/markdown/blockprocessors.py b/markdown/blockprocessors.py index d3574045..ffd206fa 100644 --- a/markdown/blockprocessors.py +++ b/markdown/blockprocessors.py @@ -172,8 +172,12 @@ def __init__(self, *args): self.INDENT_RE = re.compile(r'^(([ ]{%s})+)' % self.tab_length) def test(self, parent: etree.Element, block: str) -> bool: + # A list recurses through `parseBlocks` once per level of nesting, so the + # nesting has to stop short of the interpreter's limit. The markers which no + # longer nest are kept as text, as they are for a blockquote. return block.startswith(' '*self.tab_length) and \ not self.parser.state.isstate('detabbed') and \ + not util.nearing_recursion_limit() and \ (parent.tag in self.ITEM_TYPES or (len(parent) and parent[-1] is not None and (parent[-1].tag in self.LIST_TYPES))) @@ -354,7 +358,9 @@ def __init__(self, parser: BlockParser): (self.tab_length, self.tab_length * 2 - 1)) def test(self, parent: etree.Element, block: str) -> bool: - return bool(self.RE.match(block)) + # As in `ListIndentProcessor`, nesting stops short of the recursion limit. + # `UListProcessor` inherits this method, so unordered lists are covered too. + return bool(self.RE.match(block)) and not util.nearing_recursion_limit() def run(self, parent: etree.Element, blocks: list[str]) -> None: # Check for multiple items in one block. @@ -417,7 +423,10 @@ def run(self, parent: etree.Element, blocks: list[str]) -> None: def get_items(self, block: str) -> list[str]: """ Break a block into list items. """ - items = [] + # The lines of an item are collected in a list and joined once at the end. + # Appending per line, as in `item = '{}\n{}'.format(item, line)`, copies the + # whole item every time, which is quadratic in the number of lines of an item. + items: list[list[str]] = [] for line in block.split('\n'): m = self.CHILD_RE.match(line) if m: @@ -428,18 +437,18 @@ def get_items(self, block: str) -> list[str]: INTEGER_RE = re.compile(r'(\d+)') self.STARTSWITH = INTEGER_RE.match(m.group(1)).group() # Append to the list - items.append(m.group(3)) + items.append([m.group(3)]) elif self.INDENT_RE.match(line): # This is an indented (possibly nested) item. - if items[-1].startswith(' '*self.tab_length): + if items[-1][0].startswith(' '*self.tab_length): # Previous item was indented. Append to that item. - items[-1] = '{}\n{}'.format(items[-1], line) + items[-1].append(line) else: - items.append(line) + items.append([line]) else: # This is another line of previous item. Append to that item. - items[-1] = '{}\n{}'.format(items[-1], line) - return items + items[-1].append(line) + return ['\n'.join(item) for item in items] class UListProcessor(OListProcessor): diff --git a/tests/test_syntax/blocks/test_list_nesting.py b/tests/test_syntax/blocks/test_list_nesting.py new file mode 100644 index 00000000..b39d825e --- /dev/null +++ b/tests/test_syntax/blocks/test_list_nesting.py @@ -0,0 +1,82 @@ +""" +Python Markdown + +A Python implementation of John Gruber's Markdown. + +Documentation: https://python-markdown.github.io/ +GitHub: https://github.com/Python-Markdown/markdown/ +PyPI: https://pypi.org/project/Markdown/ + +Started by Manfred Stienstra (http://www.dwerg.net/). +Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +Currently maintained by Waylan Limberg (https://github.com/waylan), +Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +Copyright 2004 Manfred Stienstra (the original version) + +License: BSD (see LICENSE.md for details). +""" + +import markdown +from markdown.test_tools import TestCase, recursionlimit + + +class TestListNestingLimit(TestCase): + + # A list recurses through `parseBlocks` once per level of nesting, so the nesting + # has to stop short of the interpreter's limit. The remaining markers are kept as + # text, the same way a blockquote keeps its markers (see `TestBlockquoteBlocks`). + + def test_nesting_stops_short_of_the_recursion_limit(self): + # Ten levels of markers is more than the limit below allows to nest. + with recursionlimit(120): + result = markdown.markdown('- ' * 10 + 'x') + + self.assertLess(result.count('