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'))); + } +}