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