Verified against: getgrav/grav devel branch, GRAV_VERSION = "2.0.15", file `index.php
Unauthenticated Path Traversal via Missing Directory-Boundary Check in plugin-asset-map.php Static Asset Server (index.php)
getgrav/gravindex.php (top-level front controller, runs before Grav itself boots)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-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') , specific mechanism: a path-prefix containment check performed with plain string comparison (str_starts_with) 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.
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:
$assetMapFile = __DIR__ . '/user/config/plugin-asset-map.php';
if (is_file($assetMapFile)) {
$assetMap = require $assetMapFile;
foreach ($assetMap as $routePrefix => $diskPath) {
if (str_starts_with($path, $routePrefix)) {
$relPath = substr($path, strlen($routePrefix));
$filePath = __DIR__ . '/' . ltrim($diskPath, '/') . $relPath;
$realFile = realpath($filePath);
$realBase = realpath(__DIR__ . '/' . ltrim($diskPath, '/'));
if ($realFile && $realBase && str_starts_with($realFile, $realBase) && is_file($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, str_starts_with($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, assets_old, assets2, etc.) a very common real-world directory-naming pattern (backup dirs, versioned dirs, disabled/legacy dirs sitting alongside the active one).
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: SECRET_API_KEY=sk_live_totally_secret_12345
str_starts_with('.../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.
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.
Append a trailing directory separator before the prefix comparison, or use a proper containment check:
if ($realFile && $realBase && (
$realFile === $realBase ||
str_starts_with($realFile, $realBase . DIRECTORY_SEPARATOR)
) && is_file($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.
{
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-17T20:43:16Z",
"nvd_published_at": null,
"severity": "HIGH"
}