Skip to content

Commit 540d850

Browse files
committed
Fix quadratic complexity parsing long backtick code spans with no matching closers
1 parent e1cfa8d commit 540d850

File tree

2 files changed

+68
-5
lines changed

2 files changed

+68
-5
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
110110
- Fixed quadratic complexity parsing emphasis and strikethrough delimiters
111111
- Fixed issue where having 500,000+ delimiters could trigger a [known segmentation fault issue in PHP's garbage collection](https://bugs.php.net/bug.php?id=68606)
112112
- Fixed quadratic complexity deactivating link openers
113+
- Fixed quadratic complexity parsing long backtick code spans with no matching closers
113114
- Fixed catastrophic backtracking when parsing link labels/titles
114115

115116
## [2.4.1] - 2023-08-30

src/Extension/CommonMark/Parser/Inline/BacktickParser.php

+67-5
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,27 @@
1818

1919
use League\CommonMark\Extension\CommonMark\Node\Inline\Code;
2020
use League\CommonMark\Node\Inline\Text;
21+
use League\CommonMark\Parser\Cursor;
2122
use League\CommonMark\Parser\Inline\InlineParserInterface;
2223
use League\CommonMark\Parser\Inline\InlineParserMatch;
2324
use League\CommonMark\Parser\InlineParserContext;
2425

2526
final class BacktickParser implements InlineParserInterface
2627
{
28+
/**
29+
* Max bound for backtick code span delimiters.
30+
*
31+
* @see https://github.com/commonmark/cmark/commit/8ed5c9d
32+
*/
33+
private const MAX_BACKTICKS = 1000;
34+
35+
/** @var \WeakReference<Cursor>|null */
36+
private ?\WeakReference $lastCursor = null;
37+
private bool $lastCursorScanned = false;
38+
39+
/** @var array<int, int> backtick count => position of known ender */
40+
private array $seenBackticks = [];
41+
2742
public function getMatchDefinition(): InlineParserMatch
2843
{
2944
return InlineParserMatch::regex('`+');
@@ -38,11 +53,7 @@ public function parse(InlineParserContext $inlineContext): bool
3853
$currentPosition = $cursor->getPosition();
3954
$previousState = $cursor->saveState();
4055

41-
while ($matchingTicks = $cursor->match('/`+/m')) {
42-
if ($matchingTicks !== $ticks) {
43-
continue;
44-
}
45-
56+
if ($this->findMatchingTicks(\strlen($ticks), $cursor)) {
4657
$code = $cursor->getSubstring($currentPosition, $cursor->getPosition() - $currentPosition - \strlen($ticks));
4758

4859
$c = \preg_replace('/\n/m', ' ', $code) ?? '';
@@ -67,4 +78,55 @@ public function parse(InlineParserContext $inlineContext): bool
6778

6879
return true;
6980
}
81+
82+
/**
83+
* Locates the matching closer for a backtick code span.
84+
*
85+
* Leverages some caching to avoid traversing the same cursor multiple times when
86+
* we've already seen all the potential backtick closers.
87+
*
88+
* @see https://github.com/commonmark/cmark/commit/8ed5c9d
89+
*
90+
* @param int $openTickLength Number of backticks in the opening sequence
91+
* @param Cursor $cursor Cursor to scan
92+
*
93+
* @return bool True if a matching closer was found, false otherwise
94+
*/
95+
private function findMatchingTicks(int $openTickLength, Cursor $cursor): bool
96+
{
97+
// Reset the seenBackticks cache if this is a new cursor
98+
if ($this->lastCursor === null || $this->lastCursor->get() !== $cursor) {
99+
$this->seenBackticks = [];
100+
$this->lastCursor = \WeakReference::create($cursor);
101+
$this->lastCursorScanned = false;
102+
}
103+
104+
if ($openTickLength > self::MAX_BACKTICKS) {
105+
return false;
106+
}
107+
108+
// Return if we already know there's no closer
109+
if ($this->lastCursorScanned && isset($this->seenBackticks[$openTickLength]) && $this->seenBackticks[$openTickLength] <= $cursor->getPosition()) {
110+
return false;
111+
}
112+
113+
while ($ticks = $cursor->match('/`{1,' . self::MAX_BACKTICKS . '}/m')) {
114+
$numTicks = \strlen($ticks);
115+
116+
// Did we find the closer?
117+
if ($numTicks === $openTickLength) {
118+
return true;
119+
}
120+
121+
// Store position of closer
122+
if ($numTicks <= self::MAX_BACKTICKS) {
123+
$this->seenBackticks[$numTicks] = $cursor->getPosition() - $numTicks;
124+
}
125+
}
126+
127+
// Got through whole input without finding closer
128+
$this->lastCursorScanned = true;
129+
130+
return false;
131+
}
70132
}

0 commit comments

Comments
 (0)