GHSA-4v9q-p283-qc2m: Path Traversal
Verified against: getgrav/grav devel branch, GRAVVERSION = "2.0.15", file index.php
Title Unauthenticated Path Traversal via Missing Directory-Boundary Check in plugin-asset-map.php Static Asset Server (index.php)
Product / Affected Versions - Product: getgrav/grav - File: index.php (top-level front controller, runs before Grav itself boots) - Confirmed present in: devel branch, 2.0.15 - Precondition: requires user/config/plugin-asset-map.php to exist and contain at least one route-prefix mapping ,this is an opt-in mechanism (per the code comment: "Fast static asset serving for plugins that bundle SPA apps"). No core mechanism generates this file automatically; it's created by a plugin that opts into this fast-path. Not reachable on a stock Grav install with no such plugin. Where reachable, it requires zero authentication.
CWE CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') , specific mechanism: a path-prefix containment check performed with plain string comparison (strstartswith) instead of a directory-boundary-aware comparison, allowing escape into any sibling path whose name happens to extend the base directory's name as a string.
Description
index.php implements a fast-path static file server that runs before Grav's own routing/security stack, gated on the presence of an asset-map file:
php $assetMapFile = DIR . '/user/config/plugin-asset-map.php'; if (isfile($assetMapFile)) { $assetMap = require $assetMapFile; foreach ($assetMap as $routePrefix => $diskPath) { if (strstartswith($path, $routePrefix)) { $relPath = substr($path, strlen($routePrefix)); $filePath = DIR . '/' . ltrim($diskPath, '/') . $relPath; $realFile = realpath($filePath); $realBase = realpath(DIR . '/' . ltrim($diskPath, '/')); if ($realFile && $realBase && strstartswith($realFile, $realBase) && isfile($realFile)) { // ... serves $realFile directly, with Content-Type inferred from extension readfile($realFile); exit; } } } }
realpath() correctly resolves .. sequences, so a naive ../../etc/passwd-style traversal that leaves the filesystem entirely is blocked (it wouldn't share the $realBase string prefix). But the containment check itself, strstartswith($realFile, $realBase), has no directory-boundary awareness it's a plain string-prefix test, not "is $realFile inside the $realBase directory." Any resolved path whose string representation merely begins with the same characters as $realBase passes, including sibling directories that extend the base directory's name (assets → assets-secret, assets.bak, assetsold, assets2, etc.) a very common real-world directory-naming pattern (backup dirs, versioned dirs, disabled/legacy dirs sitting alongside the active one).
Live Proof of Concept
Setup: the exact code block above, extracted verbatim from index.php, executed with PHP 8.3.6 against a realistic directory layout (a plugin's active assets/ dir sitting next to an unrelated assets-secret/ dir containing a fake secret):
user/plugins/myplugin/assets/app.js <- intended, public user/plugins/myplugin/assets-secret/config.php <- NOT intended to be served user/config/plugin-asset-map.php: return ['/myplugin-assets' => 'user/plugins/myplugin/assets'];
Legitimate request (/myplugin-assets/app.js): realFile: '/home/claude/grav-poc/user/plugins/myplugin/assets/app.js' realBase: '/home/claude/grav-poc/user/plugins/myplugin/assets' >> WOULD SERVE FILE <<< >> Content: public asset content
Traversal request (/myplugin-assets/../assets-secret/config.php): realFile: '/home/claude/grav-poc/user/plugins/myplugin/assets-secret/config.php' realBase: '/home/claude/grav-poc/user/plugins/myplugin/assets' >> WOULD SERVE FILE <<< >> Content: SECRETAPIKEY=sklivetotallysecret12345
strstartswith('.../assets-secret/config.php', '.../assets') evaluates true because assets-secret literally begins with the characters assets there is no separator-boundary check (e.g. requiring $realBase . '/' as the actual prefix) to prevent this.
Trust-boundary framing (per Grav's own SECURITY.md) This code path requires no Grav account , it runs before Grav even initializes, directly off the raw request path. Per Grav's own stated criteria: "An unauthenticated attacker can achieve RCE, exfiltrate site data, or gain admin-equivalent control. No Grav account required" → this matches the CRITICAL bar exactly, for any deployment where the plugin-asset-map.php mechanism is in active use.
Suggested Fix Append a trailing directory separator before the prefix comparison, or use a proper containment check: php if ($realFile && $realBase && ( $realFile === $realBase || strstartswith($realFile, $realBase . DIRECTORYSEPARATOR) ) && isfile($realFile)) { This is the standard fix for this exact bug class , ensuring the matched prefix ends exactly at a directory boundary, not partway through a longer sibling name.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
composer/getgrav/gravto a version that resolves this vulnerability.Fixed in 2.0.15
Event History
Frequently Asked Questions
Is a default Grav installation affected?
No. The issue is not reachable on a stock Grav installation unless user/config/plugin-asset-map.php exists and contains at least one route-prefix mapping. This configuration is an opt-in fast static asset-serving mechanism used by plugins that bundle SPA applications.
What does an attacker need to exploit this?
Where the plugin asset map fast-path is configured, exploitation requires no authentication or user interaction. The attacker must be able to make network requests to the affected instance.
How can I determine whether my deployment is exposed?
Check whether user/config/plugin-asset-map.php exists and whether it defines one or more route-prefix mappings. Deployments without that file or without a mapping are not reachable through the described vulnerable path.
What can be done if an update cannot be applied immediately?
Remove or disable the route-prefix mappings in user/config/plugin-asset-map.php to prevent use of the opt-in asset-serving fast-path. The described issue is specifically dependent on at least one such mapping being present.
Which versions are confirmed affected by the available information?
The issue was verified against the getgrav/grav devel branch with GRAV_VERSION set to 2.0.15. No other affected or fixed version ranges are provided.