Fix JIT stack exhaustion in Twig3 compatibility regex patterns

The regex patterns in rewriteSameAsTests(), rewriteDivisibleByTests(),
and rewriteNoneTests() used negative lookahead `(?!\1)` which caused
catastrophic backtracking and JIT stack exhaustion when processing
content with many Unicode characters (e.g., middle-dots).

This fix uses possessive quantifiers (*+) to prevent backtracking:
- Handles single and double quotes separately (no backreference needed)
- Uses *+ possessive quantifiers to prevent backtracking
- Falls back to original content if regex fails

Fixes #4015

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy Miller
2026-02-03 18:43:20 -07:00
parent 07de0cb4e6
commit 508650583a

View File

@@ -138,17 +138,23 @@ class Twig3CompatibilityTransformer
private function rewriteSameAsTests(string $code): string
{
$pattern = '/([\'"])(?:\\\\.|(?!\\1).)*\\1|\\bis\\s+(?:not\\s+)?sameas\\b/is';
// Use possessive quantifiers (*+) to prevent catastrophic backtracking
// that can cause JIT stack exhaustion with certain Unicode content.
// Pattern matches: single-quoted strings | double-quoted strings | (keyword to replace)
$pattern = '/\'[^\'\\\\]*+(?:\\\\.[^\'\\\\]*+)*+\'|"[^"\\\\]*+(?:\\\\.[^"\\\\]*+)*+"|(\\bis\\s+(?:not\\s+)?sameas\\b)/is';
return (string) preg_replace_callback($pattern, static function ($matches) {
// If group 1 is not set, it means 'is sameas' was matched.
if (!isset($matches[1])) {
return str_ireplace('sameas', 'same as', $matches[0]);
$result = preg_replace_callback($pattern, static function ($matches) {
// Group 1 captures the keyword when matched (not inside a quoted string)
if (isset($matches[1]) && $matches[1] !== '') {
return str_ireplace('sameas', 'same as', $matches[1]);
}
// Otherwise, it's a quoted string, so return it as is.
return $matches[0];
}, $code);
// Fall back to original content on regex failure
return $result ?? $code;
}
private function rewriteReplaceFilterSignatures(string $code): string
@@ -188,32 +194,42 @@ class Twig3CompatibilityTransformer
private function rewriteDivisibleByTests(string $code): string
{
$pattern = '/([\'"])(?:\\\\.|(?!\\1).)*\\1|\\bis\\s+(?:not\\s+)?divisibleby\\b/is';
// Use possessive quantifiers (*+) to prevent catastrophic backtracking
// that can cause JIT stack exhaustion with certain Unicode content.
$pattern = '/\'[^\'\\\\]*+(?:\\\\.[^\'\\\\]*+)*+\'|"[^"\\\\]*+(?:\\\\.[^"\\\\]*+)*+"|(\\bis\\s+(?:not\\s+)?divisibleby\\b)/is';
return (string) preg_replace_callback($pattern, static function ($matches) {
// If group 1 is not set, it means 'is divisibleby' was matched.
if (!isset($matches[1])) {
return str_ireplace('divisibleby', 'divisible by', $matches[0]);
$result = preg_replace_callback($pattern, static function ($matches) {
// Group 1 captures the keyword when matched (not inside a quoted string)
if (isset($matches[1]) && $matches[1] !== '') {
return str_ireplace('divisibleby', 'divisible by', $matches[1]);
}
// Otherwise, it's a quoted string, so return it as is.
return $matches[0];
}, $code);
// Fall back to original content on regex failure
return $result ?? $code;
}
private function rewriteNoneTests(string $code): string
{
$pattern = '/([\'"])(?:\\\\.|(?!\\1).)*\\1|\\bis\\s+(?:not\\s+)?none\\b/is';
// Use possessive quantifiers (*+) to prevent catastrophic backtracking
// that can cause JIT stack exhaustion with certain Unicode content.
$pattern = '/\'[^\'\\\\]*+(?:\\\\.[^\'\\\\]*+)*+\'|"[^"\\\\]*+(?:\\\\.[^"\\\\]*+)*+"|(\\bis\\s+(?:not\\s+)?none\\b)/is';
return (string) preg_replace_callback($pattern, static function ($matches) {
// If group 1 is not set, it means 'is none' was matched.
if (!isset($matches[1])) {
return str_ireplace('none', 'null', $matches[0]);
$result = preg_replace_callback($pattern, static function ($matches) {
// Group 1 captures the keyword when matched (not inside a quoted string)
if (isset($matches[1]) && $matches[1] !== '') {
return str_ireplace('none', 'null', $matches[1]);
}
// Otherwise, it's a quoted string, so return it as is.
return $matches[0];
}, $code);
// Fall back to original content on regex failure
return $result ?? $code;
}
private function ensureWrapped(string $expression): string