{"id":"CVE-2026-74907","aliases":["GHSA-4v9q-p283-qc2m"],"url":"https://o3.security/vulnerability/CVE-2026-74907","summary":"Grav before 2.0.15 Path Traversal via plugin-asset-map.php","details":"**Verified against:** `getgrav/grav` devel branch, `GRAV_VERSION = \"2.0.15\"`, file `index.php\n\n## Title\nUnauthenticated Path Traversal via Missing Directory-Boundary Check in `plugin-asset-map.php` Static Asset Server (`index.php`)\n\n## Product / Affected Versions\n- Product: `getgrav/grav`\n- File: `index.php` (top-level front controller, runs before Grav itself boots)\n- Confirmed present in: devel branch, 2.0.15\n- **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.\n\n## CWE\nCWE-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.\n\n## Description\n\n`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:\n\n```php\n$assetMapFile = __DIR__ . '/user/config/plugin-asset-map.php';\nif (is_file($assetMapFile)) {\n    $assetMap = require $assetMapFile;\n    foreach ($assetMap as $routePrefix => $diskPath) {\n        if (str_starts_with($path, $routePrefix)) {\n            $relPath = substr($path, strlen($routePrefix));\n            $filePath = __DIR__ . '/' . ltrim($diskPath, '/') . $relPath;\n            $realFile = realpath($filePath);\n            $realBase = realpath(__DIR__ . '/' . ltrim($diskPath, '/'));\n            if ($realFile && $realBase && str_starts_with($realFile, $realBase) && is_file($realFile)) {\n                // ... serves $realFile directly, with Content-Type inferred from extension\n                readfile($realFile);\n                exit;\n            }\n        }\n    }\n}\n```\n\n`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).\n\n## Live Proof of Concept\n\n**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):\n\n```\nuser/plugins/myplugin/assets/app.js            <- intended, public\nuser/plugins/myplugin/assets-secret/config.php <- NOT intended to be served\nuser/config/plugin-asset-map.php:\n    return ['/myplugin-assets' => 'user/plugins/myplugin/assets'];\n```\n\n**Legitimate request** (`/myplugin-assets/app.js`):\n```\nrealFile: '/home/claude/grav-poc/user/plugins/myplugin/assets/app.js'\nrealBase: '/home/claude/grav-poc/user/plugins/myplugin/assets'\n>>> WOULD SERVE FILE <<<\n>>> Content: public asset content\n```\n\n**Traversal request** (`/myplugin-assets/../assets-secret/config.php`):\n```\nrealFile: '/home/claude/grav-poc/user/plugins/myplugin/assets-secret/config.php'\nrealBase: '/home/claude/grav-poc/user/plugins/myplugin/assets'\n>>> WOULD SERVE FILE <<<\n>>> Content: SECRET_API_KEY=sk_live_totally_secret_12345\n```\n\n`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.\n\n## Trust-boundary framing (per Grav's own SECURITY.md)\nThis 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.\n\n## Suggested Fix\nAppend a trailing directory separator before the prefix comparison, or use a proper containment check:\n```php\nif ($realFile && $realBase && (\n    $realFile === $realBase ||\n    str_starts_with($realFile, $realBase . DIRECTORY_SEPARATOR)\n) && is_file($realFile)) {\n```\nThis is the standard fix for this exact bug class\n, ensuring the matched prefix ends exactly at a directory boundary, not partway through a longer sibling name.","published":"2026-08-18T11:19:38.302Z","modified":"2026-09-10T03:30:17.454398675Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"getgrav/grav","fixedVersion":"2.0.15"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/74xxx/CVE-2026-74907.json"},{"type":"ADVISORY","url":"https://github.com/getgrav/grav/security/advisories/GHSA-4v9q-p283-qc2m"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-74907"},{"type":"ADVISORY","url":"https://www.vulncheck.com/advisories/grav-before-path-traversal-via-plugin-asset-map-php"},{"type":"PACKAGE","url":"https://github.com/getgrav/grav"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-10T03:30:17.454398675Z"}}