-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The sandbox was the whole defense for thirteen days\n\nGrav'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:\n\n> Twig sandbox is now the sole layer of SSTI protection on editor-authored content, the legacy regex pre-filter has been retired.\n\nThe 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`.\n\n## `toarray` was in the default allowlist\n\nThe 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:\n\n```yaml\nallowed_methods:\n ...\n - class: 'Grav\\Common\\Config\\Config'\n methods: 'get, toarray, value, default, offsetget, offsetexists'\n ...\n - class: 'Grav\\Common\\Data\\Data'\n methods: 'get, value, toarray, items, offsetget, offsetexists, __tostring'\n```\n\nSix 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.\n\n## `Config::toArray()` returns the merged plugin secret tree\n\n`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/.yaml`.\n\nEvery 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.\n\nThe 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.\n\n## The PoC is the page body\n\nThe 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:\n\n```yaml\n---\ntitle: Reflected\nprocess:\n twig: true\n---\n{{ config.toArray()|json_encode|raw }}\n```\n\nSave 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:\n\n```php\nif ($item->shouldProcess('twig')) {\n $name = '@Page:' . $item->path();\n $this->setTemplate($name, $content);\n try {\n $output = $local_twig->render($name, $twig_vars);\n } catch (SecurityError $e) {\n ...\n }\n}\n```\n\nThe 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.\n\n## Grav's own threat model names this exact boundary\n\nThe 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:\n\n> 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.\n\nAnd the severity table:\n\n> **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.\n\nThe 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.\n\n## The fix replaced the object, not the method name\n\nThe 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:\n\n```diff\n - class: 'Grav\\Common\\Config\\Config'\n- methods: 'get, toarray, value, default, offsetget, offsetexists'\n+ methods: 'get, value, default, offsetget, offsetexists'\n+ - class: 'Grav\\Common\\Twig\\Sandbox\\SandboxConfig'\n+ methods: 'get, toarray, value, offsetget, offsetexists'\n - class: 'Grav\\Common\\Page\\Interfaces\\PageInterface'\n ...\n - class: 'Grav\\Common\\Data\\Data'\n- methods: 'get, value, toarray, items, offsetget, offsetexists, __tostring'\n+ methods: 'get, value, items, offsetget, offsetexists, __tostring'\n```\n\nLayer 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:\n\n```php\npublic function toArray(): array\n{\n return $this->filterArray($this->config->toArray(), '');\n}\n```\n\nThe denied list comes from a new config knob, `security.twig_sandbox.config_denied_paths`, which ships with five defaults:\n\n```yaml\nconfig_denied_paths:\n - plugins\n - streams\n - security\n - backups\n - scheduler\n```\n\nThose 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.\n\nLayer 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)`:\n\n```php\n$sandbox_vars = $twig_vars;\n$sandbox_vars['config'] = $this->buildSandboxConfig();\ntry {\n $output = $local_twig->render($name, $sandbox_vars);\n}\n```\n\nThe 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.\n\nThe 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.\n\n## This is the second sandbox-to-Config leak in thirteen days\n\nThe 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`](https://github.com/getgrav/grav/security/advisories/GHSA-3f29-pqwf-v4j4):\n\n> 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.\n\nThe 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.\n\nThe 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.\n\nTwo 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.\n\nReference: [GHSA-j274-39qw-32c9](https://github.com/getgrav/grav/security/advisories/GHSA-j274-39qw-32c9). Reporter: Revanth011. Patch commit: [getgrav/grav@58acfee4](https://github.com/getgrav/grav/commit/58acfee40e647a4b5903df2b1944bbf1ec8dd526).","closing_line":"Six characters of YAML, thirteen days, every plugin credential.","hook_md":"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.","post_id":303,"slug":"grav-cve-2026-44738-toarray-was-on-the-allowlist","title":"CVE-2026-44738: `toarray` Was On Grav's Twig Sandbox Allowlist For Thirteen Days.","type":"initial","unreadable_sentence":"The trust boundary the sandbox was introduced to enforce was the boundary the sandbox's default configuration crossed."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCagosQQAKCRDeZjl4jgkQ JgiXAP984d5qZeJIjVcI/YNL6/9k2hEKRSoaeBZpR+ZdU2aB0gD/Ss23h97b6fDc FMRUDsgS41o/9Tnse8NzBJrlP3PgFAg= =pD9R -----END PGP SIGNATURE-----