VulnWatch VulnWatch
← Back to dashboard
High github · GHSA-p597-crqc-m349

Grav: The system, site, and theme Twig variables bypass the content sandbox entirely and are never covered by config_denied_paths

Published Sep 17, 2026 CVSS 6.5

Summary

Grav\Common\Twig\Twig::init() unconditionally puts the raw system, site, and theme config arrays into $this->twig_vars. Twig::processPage() builds the variables for the sandboxed, editor-authored page-content render by copying that same base array ($sandbox_vars = $twig_vars;) and replacing only the config key with a filtered SandboxConfig facade. The system, site, and theme keys are carried into the sandboxed render completely untouched.

Because these are plain PHP arrays, not objects, Twig's sandbox SecurityPolicy (the allowed_classes/allowed_methods/allowed_properties lists in system/config/security.yaml) has no jurisdiction over them at all. The sandbox only gates method calls and property access on objects. Dot notation or subscript access on an array is always allowed by Twig regardless of any sandbox policy. So {{ system.cache.redis.password }} in page content renders the value directly, with the sandbox doing nothing to stop it, and with security.twig_sandbox.config_denied_paths never even being consulted, since that list only filters the separate config facade object, not the system array.

This means: even on a default install where twig_content.config_access is false (its documented default) so the config Twig variable is empty inside sandboxed renders, an attacker with page-content edit access (or a stored-XSS-style Twig injection into page content, if twig_content.process_enabled is on) can still read system.*, site.*, and theme.* in full, including any admin-configured secret nested under those trees.

Affected product and version

Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3

Affected code

system/src/Grav/Common/Twig/Twig.php, in init(), the base variable set (around line 300):

$this->twig_vars += [
        'config'            => $config,
        'system'            => $config->get('system'),
        'theme'             => $config->get('theme'),
        'site'              => $config->get('site'),
        'uri'               => $this->grav['uri'],
        ...
    ];

system/src/Grav/Common/Twig/Twig.php, in processPage(), where the sandboxed render variables are built (around line 419-429):

if ($item->shouldProcess('twig') || $item->isModule()) {
    $name = '@Page:' . $item->path();
    $this->setTemplate($name, $content);
    // Replace `config` with a denied-path-filtered facade for the
    // sandboxed render so editors can't exfiltrate plugin secrets
    // via `config.toArray()` (GHSA-j274-39qw-32c9). The modular
    // theme render below is unsandboxed and keeps the raw Config.
    $sandbox_vars = $twig_vars;
    $sandbox_vars['config'] = $this->buildSandboxConfig();
    try {
        $output = $content = $local_twig->render($name, $sandbox_vars);
    ...

Only $sandbox_vars['config'] is replaced. $sandbox_vars['system'], $sandbox_vars['site'], and $sandbox_vars['theme'] still point at the exact same raw arrays that were assigned in init().

system/config/system.yaml shows a concrete real secret field that lives under system:

cache:
  redis:
    socket: false
    password:                                    # Optional password
    database:

Root cause

Two separate things have to both be true for this to be reachable, and they both are:

  1. The sandbox's SecurityPolicy only checks object method calls and object property access (checkMethodAllowed, checkPropertyAllowed in Twig's Sandbox\SecurityPolicy). It has no concept of restricting array key access, because Twig's own design does not treat plain array reads as something a sandbox policy needs to arbitrate. config_denied_paths is implemented entirely inside SandboxConfig, a wrapper object with its own get()/offsetGet() that consults the denied list, that facade is what makes config safe. system/site/theme never get wrapped in anything like it, they are passed straight through as arrays.

  2. processPage()'s sandboxed variable set is built by copying the entire pre-existing $twig_vars array and only patching the one key (config) that the GHSA-j274-39qw-32c9 fix was scoped to. system, site, and theme were already sitting in that array before the sandboxed path was ever reached, and nothing removes or filters them for that specific render.

Proof of concept, verified, real output

I verified this at two levels: first that the raw Grav source really does copy system into the sandboxed variables unfiltered (shown above via direct file reading of system/src/Grav/Common/Twig/Twig.php, not a paraphrase), and second, since I do not have a fully bootstrapped live Grav site available in this sandbox (composer install needs packagist.org, unreachable here), I verified the actual mechanism, that Twig's sandbox cannot restrict array access no matter how strict the policy is, by running it against the exact, real Twig source Grav has pinned.

Step 1, get the exact Twig commit Grav's composer.lock points at:

$ python3 -c "
import json
d = json.load(open('composer.lock'))
for pkg in d['packages']:
    if pkg['name'] == 'twig/twig':
        print(pkg['source'])
"
{'type': 'git', 'url': 'https://github.com/getgrav/Twig.git', 'reference': '24d7a0e821cf573496d99e05d6bd9d1a42f822c7'}

Step 2, clone that exact commit:

$ git clone https://github.com/getgrav/Twig.git twig-src
$ cd twig-src && git checkout 24d7a0e821cf573496d99e05d6bd9d1a42f822c7
HEAD is now at 24d7a0e8 Merge branch 'twigphp:3.x' into 3.x

Step 3, PoC script. This builds a SecurityPolicy with an empty allowed_classes, allowed_methods, and allowed_properties list, deliberately stricter than Grav's real policy, to show that even a maximally locked down object policy still cannot stop array key access, then renders {{ system.cache.redis.password }} against a system variable shaped exactly like what $config->get('system') returns in real Grav:

Affected AI Products

claude
Get the weekly digest. Every Monday: top AI security stories of the week. Free.