//nefariousplan

CVE-2026-44738: `toarray` Was On Grav's Twig Sandbox Allowlist For Thirteen Days.

patterns

cve

proof of concept

Grav 2.0.0-beta.2 shipped on 25 April 2026 with a new Twig content sandbox: an allowlist policy covering tags, filters, functions, methods, and properties. Three days later, 2.0.0-beta.3 retired the legacy regex pre-filter, and the CHANGELOG noted, "Twig sandbox is now the sole layer of SSTI protection on editor-authored content." On 8 May 2026, thirteen days after the sandbox shipped and ten days after it became the only defense, Andy Miller landed commit 58acfee40e64. The patch removes toarray from one line in system/config/security.yaml. The line is the method allowlist for Grav\Common\Config\Config. With toarray on it, a user holding the admin.pages role could save a page body of {{ config.toArray()|json_encode|raw }}, view the page, and read the merged Grav configuration as JSON in the response HTML. The merged Grav configuration is where every plugin's credentials live.

The sandbox was the whole defense for thirteen days

Grav's Twig content sandbox is recent. The component the sandbox replaced lived in Security::cleanDangerousTwig(), a regex pre-filter that pattern-matched forbidden tokens in page content before handing it to Twig. The CHANGELOG entry for v2.0.0-beta.2 (25 April 2026) introduces the sandbox as a Twig\Sandbox\SecurityPolicy over tags, filters, functions, methods, and properties. The CHANGELOG entry for v2.0.0-beta.3 (28 April 2026) names the moment the sandbox became the only protection:

Twig sandbox is now the sole layer of SSTI protection on editor-authored content, the legacy regex pre-filter has been retired.

The retirement removed Security::cleanDangerousTwig(), the matching whitelist/blacklist config block, every call site that wrapped page content in the regex pass before handing it to Twig, and the test file. After 28 April, the sandbox carries the whole defense. The sandbox's behavior is decided by one file that ships with Grav: system/config/security.yaml.

toarray was in the default allowlist

The default allowlist for class methods in security.yaml is what decides which methods page-content Twig can call on which classes. The pre-patch entry for Grav\Common\Config\Config reads:

allowed_methods:
  ...
  - class: 'Grav\Common\Config\Config'
    methods: 'get, toarray, value, default, offsetget, offsetexists'
  ...
  - class: 'Grav\Common\Data\Data'
    methods: 'get, value, toarray, items, offsetget, offsetexists, __tostring'

Six tokens on the Config line, seven on the Data line. toarray is one of them on both. Config extends Data, and Data is the runtime type of every Grav data wrapper that flows into a sandboxed render. Both classes appear in the variable bag passed to Twig::processPage() under names a publisher can reach from their page body.

Config::toArray() returns the merged plugin secret tree

Grav\Common\Config\Config extends Grav\Common\Data\Data, and Data uses the RocketTheme\Toolbox\ArrayTraits\Export trait. The trait's toArray() returns $this->items unmodified. At request time, $this->items is the merged tree assembled by ConfigServiceProvider::load(): every file under system/config/*.yaml, every file under user/config/*.yaml, every blueprint default for every installed plugin, and every user/config/plugins/<plugin>.yaml.

Every Grav plugin writes its settings under the plugins key in that merged tree. The Email plugin stores plugins.email.mailer.smtp.password. A reCAPTCHA plugin stores plugins.recaptcha.secret_key. An AWS-S3 backups plugin stores plugins.aws.access_key and plugins.aws.secret_key. An OAuth plugin stores plugins.oauth.client_secret. None of those values are in scope for an editor with the admin.pages role to read or write. They are surfaced in the admin UI under tabs the page-edit role does not see, and they are saved by the super-admin role through configuration screens the page-edit role cannot reach.

The sandbox's allowlist line said toarray. The method named toarray returned the file-by-file merged YAML tree the super-admin had written. The allowlist's grain is the method. The security boundary lives one layer deeper, inside the container the method returns.

The PoC is the page body

The full chain is one page edit and one page view. Authenticate as a user with the admin.pages role. Pages with process.twig: true in their frontmatter pass through the sandboxed Twig render in Twig::processPage(). The process.twig flag is per-page metadata, and there is no super-admin gate on flipping it: any role with create/update on pages can write the flag. Create the page:

---
title: Reflected
process:
  twig: true
---
{{ config.toArray()|json_encode|raw }}

Save and view it. Twig::processPage() registers the body as the @Page: source name, hands it to the sandboxed environment with the standard twig_vars, and renders:

if ($item->shouldProcess('twig')) {
    $name = '@Page:' . $item->path();
    $this->setTemplate($name, $content);
    try {
        $output = $local_twig->render($name, $twig_vars);
    } catch (SecurityError $e) {
        ...
    }
}

The sandboxed environment consults the policy, sees toarray allowed on Grav\Common\Config\Config, and lets the call through. config.toArray() returns the merged tree. json_encode serializes it. raw tells Twig not to HTML-escape the JSON. The response body of the page is the JSON. One curl http://target/reflected reads it. A search of that JSON for password, secret, key, token enumerates every plugin credential the deployment holds.

Grav's own threat model names this exact boundary

The maintainer who landed the patch also rewrote SECURITY.md two days later, on 10 May 2026. The new section "Note on Security Severity" defines what counts as a vulnerability in Grav:

A vulnerability is when an actor can escape the trust scope of their role: a publisher whose stored content compromises an admin session, an unauthenticated visitor who reaches a privileged sink, an account at any tier that gains capabilities it was not granted.

And the severity table:

HIGH: A cross-trust-boundary issue. A lower-privilege actor (or anonymous visitor against a stored payload) ends up running code, exfiltrating data, or taking actions inside a higher-privilege session.

The Twig content sandbox is the mechanism that holds the publisher/admin trust boundary in Grav. The sandbox's job is to let publishers render Twig in their pages (that's in scope for the role) without letting publishers reach admin-tier data (that's the boundary). CVE-2026-44738 is the boundary failing on one line in the sandbox's default allowlist. The default config that ships with the sandbox routes a publisher straight through to admin-tier configuration. The trust boundary the sandbox was introduced to enforce was the boundary the sandbox's default configuration crossed.

The fix replaced the object, not the method name

The patch lands in three layers. Layer one is in security.yaml: drop toarray from the raw Config and Data allowlist entries. The change is exactly the deletion of the word from both lines:

     - class: 'Grav\Common\Config\Config'
-      methods: 'get, toarray, value, default, offsetget, offsetexists'
+      methods: 'get, value, default, offsetget, offsetexists'
+    - class: 'Grav\Common\Twig\Sandbox\SandboxConfig'
+      methods: 'get, toarray, value, offsetget, offsetexists'
     - class: 'Grav\Common\Page\Interfaces\PageInterface'
       ...
     - class: 'Grav\Common\Data\Data'
-      methods: 'get, value, toarray, items, offsetget, offsetexists, __tostring'
+      methods: 'get, value, items, offsetget, offsetexists, __tostring'

Layer two is a new class, Grav\Common\Twig\Sandbox\SandboxConfig. It is a read-only facade around the real Config. Its toArray() calls the real Config::toArray(), then walks a list of denied dot-prefixes, recursively unsetting matching subtrees before returning:

public function toArray(): array
{
    return $this->filterArray($this->config->toArray(), '');
}

The denied list comes from a new config knob, security.twig_sandbox.config_denied_paths, which ships with five defaults:

config_denied_paths:
  - plugins
  - streams
  - security
  - backups
  - scheduler

Those five prefixes cover the categories of the merged Config tree that the merge process fills with operator-only state: plugin configuration (credentials), stream definitions (filesystem layout), the rest of the security: block (XSS lists, sandbox toggles), backup destinations, and scheduler jobs.

Layer three is the wiring in Twig::processPage() and Twig::processString(). When the sandboxed render begins, the config Twig variable is rebound from the raw Config to a freshly constructed SandboxConfig($config, $denied):

$sandbox_vars = $twig_vars;
$sandbox_vars['config'] = $this->buildSandboxConfig();
try {
    $output = $local_twig->render($name, $sandbox_vars);
}

The allowlist now lists SandboxConfig::toArray (allowed) and not Config::toArray (blocked). Trusted theme templates and modular renders, which run outside the sandbox, continue to see the raw Config and its full plugin configuration. The published unit tests assert both halves explicitly: a sandboxed render of {{ config.toArray()|json_encode|raw }} with a SandboxConfig injected as config produces output containing the public site title and not containing PLUGIN_SECRET_42, while the same template with a raw Config passed as config raises SecurityNotAllowedMethodError at render time.

The choice not to remove toArray from the sandbox vocabulary entirely is deliberate. Editors who want a bulk view of the safe subset of config can still get one. The substitution is at the type level, not the call-site level. The allowlist's grain is "this method on this class is callable." Layer two of the patch ships a different class with the same method name that returns filtered data, and layer three swaps that class in at the variable that publishers reach.

This is the second sandbox-to-Config leak in thirteen days

The same advisory shape was closed once already, with a different scalar, in the release that introduced the sandbox. Grav 2.0.0-beta.2's CHANGELOG entry for GHSA-3f29-pqwf-v4j4:

Fixed salt disclosure via sandboxed Twig (GHSA-3f29-pqwf-v4j4): the HMAC key used for CSRF nonces and admin rate-limit hashing has moved out of Config into user/config/security-private.php (blocked from web access by the default user/*.php htaccess rule), so {{ grav.config.get('security.salt') }} no longer leaks it.

The 3f29 fix removed one scalar (the HMAC salt) from the merged Config tree and stored it in a separate PHP file outside Config's loading path. It closed the specific exfil channel for the specific value. It did not address the substrate that produced the channel: the sandbox grants editors method-level access on the merged Config object, and the merged Config object is where every plugin's credentials live; any path the allowlist permits is a path to those credentials.

The 3f29 PoC was {{ grav.config.get('security.salt') }}, a single-key read of a single secret, and the fix removed the secret. The j274 PoC is {{ config.toArray()|json_encode|raw }}, a bulk read of every secret, and the fix removed the bulk method and replaced the underlying object. The same substrate produced both. The sandbox's allowlist was not the surface where either fix landed. It could not be, because the allowlist's grain is the method name, and the security boundary is the data the method returns.

Two CVEs against the same component, thirteen days apart, with the same shape. The first patch fixes the specific value the PoC named. The second patch fixes the type system around the container. The component itself, the allowlist-grain-is-the-method enforcement model of sandbox protection, is the design-debt driver. Each patch closes the specific exfil; the substrate keeps producing the next instance. On 25 April 2026 the sandbox was the answer to SSTI. On 8 May 2026 the sandbox was the answer to one specific bulk-getter on its own allowlist. Whatever is on the allowlist that returns a different container in a future release will be the answer to the next CVE.

Reference: GHSA-j274-39qw-32c9. Reporter: Revanth011. Patch commit: getgrav/grav@58acfee4.

Six characters of YAML, thirteen days, every plugin credential.