From 508650583aae56b6016ba68bdfa4200f5253fd78 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Tue, 3 Feb 2026 18:43:20 -0700 Subject: [PATCH] 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 --- .../Twig3CompatibilityTransformer.php | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityTransformer.php b/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityTransformer.php index 9ef45d5bf..f51d26e35 100644 --- a/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityTransformer.php +++ b/system/src/Grav/Common/Twig/Compatibility/Twig3CompatibilityTransformer.php @@ -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