From f1674e7a4ea775d0f5fa9bcad748a07d8a706e87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:26:49 +0000 Subject: [PATCH 1/3] Limit the nesting depth of blockquotes Each level of blockquote nesting re-processes and re-indents everything inside it, so memory use grows with the square of the depth: a text of 1000 ">" characters (1 KB) needs more than 512 MB. Any application which renders Markdown from untrusted users can be made to run out of memory. The new option $max_blockquote_depth (default 32, 0 for no limit) stops looking for nested blockquotes at that depth; deeper ">" markers are output as text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NqKnhw6Y9HUoKubsfRNp1C --- Michelf/Markdown.php | 21 +++++++++ test/unit/MarkdownBlockQuoteTest.php | 66 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 test/unit/MarkdownBlockQuoteTest.php diff --git a/Michelf/Markdown.php b/Michelf/Markdown.php index 746a2d04..9fed2c96 100644 --- a/Michelf/Markdown.php +++ b/Michelf/Markdown.php @@ -119,6 +119,14 @@ public static function defaultTransform(string $text): string { */ public bool $enhanced_ordered_list = false; + /** + * Maximum nesting depth of blockquotes. Deeper ">" markers are output + * as text. Each level of nesting re-processes and re-indents everything + * inside it, so without a limit a few kilobytes of ">" characters take + * gigabytes of memory. Set to 0 for no limit. + */ + public int $max_blockquote_depth = 32; + /** * Parser implementation */ @@ -180,6 +188,11 @@ public function __construct() { */ protected bool $in_emphasis_processing = false; + /** + * Nesting depth of the blockquote currently being processed. + */ + protected int $blockquote_depth = 0; + /** * Called before the transformation process starts to setup parser states. * @return void @@ -191,6 +204,7 @@ protected function setup() { $this->html_hashes = array(); $this->in_anchor = false; $this->in_emphasis_processing = false; + $this->blockquote_depth = 0; } /** @@ -1421,6 +1435,11 @@ protected function doItalicsAndBold($text) { * @return string */ protected function doBlockQuotes($text) { + if ($this->max_blockquote_depth > 0 && + $this->blockquote_depth >= $this->max_blockquote_depth) { + return $text; + } + $text = preg_replace_callback('/ ( # Wrap whole match in $1 (?> @@ -1445,7 +1464,9 @@ protected function _doBlockQuotes_callback($matches) { $bq = $matches[1]; // trim one level of quoting - trim whitespace-only lines $bq = preg_replace('/^[ ]*>[ ]?|^[ ]+$/m', '', $bq); + $this->blockquote_depth++; $bq = $this->runBlockGamut($bq); // recurse + $this->blockquote_depth--; $bq = preg_replace('/^/m', " ", $bq); // These leading spaces cause problem with
 content,
diff --git a/test/unit/MarkdownBlockQuoteTest.php b/test/unit/MarkdownBlockQuoteTest.php
new file mode 100644
index 00000000..8aae27cc
--- /dev/null
+++ b/test/unit/MarkdownBlockQuoteTest.php
@@ -0,0 +1,66 @@
+}', $html, $tags);
+		foreach ($tags[1] as $closing) {
+			$depth += $closing === '' ? 1 : -1;
+			$deepest = max($deepest, $depth);
+		}
+		return $deepest;
+	}
+
+	public function deepBlockQuotes(): array
+	{
+		return array(
+			'markers' => array(str_repeat('>', 2000) . ' x'),
+			'markers with spaces' => array(str_repeat('> ', 2000) . 'x'),
+			'one level more per line' => array(implode("\n", array_map(function ($i) {
+				return str_repeat('>', $i) . ' x';
+			}, range(1, 300)))),
+			'in a list item' => array("- a\n\n    " . str_repeat('>', 2000) . ' x'),
+		);
+	}
+
+	/**
+	 * @dataProvider deepBlockQuotes
+	 */
+	public function testNestingIsLimited(string $text)
+	{
+		// without the limit, these need from hundreds of megabytes to gigabytes of memory
+		foreach (array(new \Michelf\Markdown(), new \Michelf\MarkdownExtra()) as $parser) {
+			$html = $parser->transform($text);
+			$this->assertSame($parser->max_blockquote_depth, self::blockQuoteDepth($html));
+		}
+	}
+
+	public function testMarkersBeyondTheLimitAreText()
+	{
+		$parser = new \Michelf\Markdown();
+		$parser->max_blockquote_depth = 2;
+
+		$this->assertSame(
+			"
\n
\n

> x

\n
\n
", + trim($parser->transform('>>> x')) + ); + $this->assertSame( + "
\n
\n

y

\n
\n
", + trim($parser->transform('>> y')), + 'the depth starts again at 0 for each transformation' + ); + } + + public function testLimitCanBeDisabled() + { + $parser = new \Michelf\Markdown(); + $parser->max_blockquote_depth = 0; + + $this->assertSame(150, self::blockQuoteDepth($parser->transform(str_repeat('>', 150) . ' x'))); + } +} From b0ecc60076dae15014737e74f96f5d051a665c71 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 07:16:14 +0000 Subject: [PATCH 2/3] Fix errors reported by PHPStan 2.x The CI job runs `phpstan analyse Michelf/ --level=5` with an unpinned `phpstan/phpstan` (>=1.0), so it now picks up 2.x, which reports seven errors in code that was already on `lib`: - `@copyright (Original Markdown) ...` in the three file headers: a tag value starting with "(" is parsed as annotation arguments and fails. Drop the parentheses. - Markdown::_doLists_callback(): `$list_type == 'ol'` is always true where it is tested, because `$ol_start > 1` is only reachable for ordered lists. Remove the redundant comparison. - Markdown::handleSpanToken(): pass the regex delimiter to preg_quote(). The token is only backticks, so the pattern is unchanged. - MarkdownExtra::doExtraAttributes(): document `$attr` as `string|null`; callers pass possibly unset matches and the body already handles null. - MarkdownExtra::parseFootnotePlaceholders(): cast the numbers to string for str_replace(). No change in output: the 60 bundled fixtures and a ~4,000 case corpus render identically with both parsers before and after. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013TVvenPEq6D9BgMuwN7Cam --- Michelf/Markdown.php | 6 +++--- Michelf/MarkdownExtra.php | 6 +++--- Michelf/MarkdownInterface.php | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Michelf/Markdown.php b/Michelf/Markdown.php index 9fed2c96..308daeeb 100644 --- a/Michelf/Markdown.php +++ b/Michelf/Markdown.php @@ -5,7 +5,7 @@ * @package php-markdown * @author Michel Fortin * @copyright 2004-2022 Michel Fortin - * @copyright (Original Markdown) 2004-2006 John Gruber + * @copyright Original Markdown 2004-2006 John Gruber */ namespace Michelf; @@ -1093,7 +1093,7 @@ protected function _doLists_callback($matches) { } } - if ($ol_start > 1 && $list_type == 'ol'){ + if ($ol_start > 1){ $result = $this->hashBlock("<$list_type start=\"$ol_start\">\n" . $result . ""); } else { $result = $this->hashBlock("<$list_type>\n" . $result . ""); @@ -1803,7 +1803,7 @@ protected function handleSpanToken($token, &$str) { return $this->hashPart("&#". ord($token[1]). ";"); case "`": // Search for end marker in remaining text. - if (preg_match('/^(.*?[^`])'.preg_quote($token).'(?!`)(.*)$/sm', + if (preg_match('/^(.*?[^`])'.preg_quote($token, '/').'(?!`)(.*)$/sm', $str, $matches)) { $str = $matches[2]; diff --git a/Michelf/MarkdownExtra.php b/Michelf/MarkdownExtra.php index 78e265d4..340fdf14 100644 --- a/Michelf/MarkdownExtra.php +++ b/Michelf/MarkdownExtra.php @@ -5,7 +5,7 @@ * @package php-markdown * @author Michel Fortin * @copyright 2004-2022 Michel Fortin - * @copyright (Original Markdown) 2004-2006 John Gruber + * @copyright Original Markdown 2004-2006 John Gruber */ namespace Michelf; @@ -218,7 +218,7 @@ protected function teardown() { * which will be used to populate the id attribute in case it was not * overridden. * @param string $tag_name - * @param string $attr + * @param string|null $attr * @param mixed $defaultIdValue * @param array $classes * @return string @@ -1796,7 +1796,7 @@ protected function _appendFootnotes_callback($matches) { protected function parseFootnotePlaceholders($label, $footnote_number, $reference_number) { return str_replace( array('^^', '%%'), - array($footnote_number, $reference_number), + array((string) $footnote_number, (string) $reference_number), $label ); } diff --git a/Michelf/MarkdownInterface.php b/Michelf/MarkdownInterface.php index 84e65459..ede9155d 100644 --- a/Michelf/MarkdownInterface.php +++ b/Michelf/MarkdownInterface.php @@ -5,7 +5,7 @@ * @package php-markdown * @author Michel Fortin * @copyright 2004-2022 Michel Fortin - * @copyright (Original Markdown) 2004-2006 John Gruber + * @copyright Original Markdown 2004-2006 John Gruber */ namespace Michelf; From 41c2c0a0ddbed4ea965a45468dcb9bf9dc5582f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 07:39:53 +0000 Subject: [PATCH 3/3] Revert "Fix errors reported by PHPStan 2.x" These are pre-existing errors on `lib` unrelated to this pull request. They are fixed separately in their own pull request, so this one stays focused on its own change. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013TVvenPEq6D9BgMuwN7Cam --- Michelf/Markdown.php | 6 +++--- Michelf/MarkdownExtra.php | 6 +++--- Michelf/MarkdownInterface.php | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Michelf/Markdown.php b/Michelf/Markdown.php index 308daeeb..9fed2c96 100644 --- a/Michelf/Markdown.php +++ b/Michelf/Markdown.php @@ -5,7 +5,7 @@ * @package php-markdown * @author Michel Fortin * @copyright 2004-2022 Michel Fortin - * @copyright Original Markdown 2004-2006 John Gruber + * @copyright (Original Markdown) 2004-2006 John Gruber */ namespace Michelf; @@ -1093,7 +1093,7 @@ protected function _doLists_callback($matches) { } } - if ($ol_start > 1){ + if ($ol_start > 1 && $list_type == 'ol'){ $result = $this->hashBlock("<$list_type start=\"$ol_start\">\n" . $result . ""); } else { $result = $this->hashBlock("<$list_type>\n" . $result . ""); @@ -1803,7 +1803,7 @@ protected function handleSpanToken($token, &$str) { return $this->hashPart("&#". ord($token[1]). ";"); case "`": // Search for end marker in remaining text. - if (preg_match('/^(.*?[^`])'.preg_quote($token, '/').'(?!`)(.*)$/sm', + if (preg_match('/^(.*?[^`])'.preg_quote($token).'(?!`)(.*)$/sm', $str, $matches)) { $str = $matches[2]; diff --git a/Michelf/MarkdownExtra.php b/Michelf/MarkdownExtra.php index 340fdf14..78e265d4 100644 --- a/Michelf/MarkdownExtra.php +++ b/Michelf/MarkdownExtra.php @@ -5,7 +5,7 @@ * @package php-markdown * @author Michel Fortin * @copyright 2004-2022 Michel Fortin - * @copyright Original Markdown 2004-2006 John Gruber + * @copyright (Original Markdown) 2004-2006 John Gruber */ namespace Michelf; @@ -218,7 +218,7 @@ protected function teardown() { * which will be used to populate the id attribute in case it was not * overridden. * @param string $tag_name - * @param string|null $attr + * @param string $attr * @param mixed $defaultIdValue * @param array $classes * @return string @@ -1796,7 +1796,7 @@ protected function _appendFootnotes_callback($matches) { protected function parseFootnotePlaceholders($label, $footnote_number, $reference_number) { return str_replace( array('^^', '%%'), - array((string) $footnote_number, (string) $reference_number), + array($footnote_number, $reference_number), $label ); } diff --git a/Michelf/MarkdownInterface.php b/Michelf/MarkdownInterface.php index ede9155d..84e65459 100644 --- a/Michelf/MarkdownInterface.php +++ b/Michelf/MarkdownInterface.php @@ -5,7 +5,7 @@ * @package php-markdown * @author Michel Fortin * @copyright 2004-2022 Michel Fortin - * @copyright Original Markdown 2004-2006 John Gruber + * @copyright (Original Markdown) 2004-2006 John Gruber */ namespace Michelf;