GitHub Advisoryadvisory · Packagist
GHSA-47ch-6w46-6xm7 Grav: media_directory() Twig function allows filesystem path traversal and file content disclosure from sandboxed page content
HIGH
Official page ↗
- Aliases
- CVE-2026-72697
- Ecosystem
- Packagist
- Packages
- getgrav/grav
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
- Weaknesses
- CWE-22
- Reviewed
- 2026-09-17
Affected packages
• Packagist getgrav/grav: < 2.0.16 → fixed in 2.0.16
Description
Summary
The `media_directory()` Twig function is allow-listed for use in sandboxed, editor-authored page content (`system/config/security.yaml`). Its implementation, `GravExtension::mediaDirFunc()`, only treats the input as unsafe when it looks like a Grav stream (`user://`, `theme://`, etc). If the input is instead a plain filesystem path, absolute or relative, the stream check is skipped entirely and the raw string is handed straight to `new Media($media_dir)`, which lists every file in that directory whose extension matches a configured media type (which by default includes `txt`, `json`, `xml`, `pdf`, `doc`, `docx`, and more, not just images) and builds `Medium` objects for them.
Separately, the sandbox's own allow-list for the `Medium` class includes the `filepath` accessor. A code comment directly above that allow-list entry states the developers' intent was for `filepath` to be part of the "dangerous surface" that "stays blocked", but it is listed as an allowed method on the very same line, contradicting that stated intent.
Combined, a user who can enter page content that gets processed as Twig (`process.twig: true` in frontmatter, or any modular page, which is unsandboxed and unconditional per the code comment in `processPage()`) can point `media_directory()` at any directory the web server process can read, anywhere on the filesystem, and both enumerate and read the content of any file in it whose extension is a recognized media type.
Affected product and version
Product: Grav CMS, getgrav/grav
Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3
Affected code
`system/src/Grav/Common/Twig/Extension/GravExtension.php`, `mediaDirFunc()`:
```php
public function mediaDirFunc($media_dir)
{
/** @var UniformResourceLocator $locator */
$locator = $this->grav['locator'];
if ($locator->isStream($media_dir)) {
$media_dir = $locator->findResource($media_dir);
}
if ($media_dir && file_exists($media_dir)) {
return new Media($media_dir);
}
return null;
}
```
There is no check that `$media_dir`, when it is not a recognized stream, is contained within any site-relative root. It is used exactly as supplied.
`system/src/Grav/Common/Page/Media.php`, `init()`, called from the constructor:
```php
protected function init()
{
$path = $this->getPath();
// Handle special cases where page doesn't exist in filesystem.
if (!$path || !is_dir($path)) {
return;
}
...
$iterator = new FilesystemIterator($path, FilesystemIterator::UNIX_PATHS | FilesystemIterator::SKIP_DOTS);
foreach ($iterator as $file => $info) {
...
[$basename, $ext, $type, $extra] = $this->getFileParts($filename);
if (!in_array(strtolower((string) $ext), $media_types, true)) {
continue;
}
...
}
```
`$path` here is whatever was passed to the `Media` constructor, the raw, unvalidated string from `mediaDirFunc()`.
`system/config/security.yaml`, the `Medium` sandbox allow-list and the comment directly above it:
```yaml
# ...
# dangerous surface (save, set, copy, deleteFile, toArray, filepath, …) is
# absent from ALLOWED_ACTIONS and stays blocked.
• class: 'Grav\Common\Page\Medium\Medium'
methods: 'url, html, filepath, filename, metadata, srcset, parsedownelement, __tostring, @media_actions'
```
`filepath` is named in the comment as something that is supposed to stay blocked, and is then listed as an allowed method one line later.
`media_directory` in the sandbox's function allow-list:
```
• media_directory
```
Root cause
Two gaps, and the second one converts the first from "list filenames from a directory" into "read the content of files":
1. `mediaDirFunc()`'s containment check only fires for recognized Grav streams. A plain filesystem path, which is the normal, documented shape of a string, is not a stream by definition, so it always takes the unchecked path.
2. Once a `Medium` object exists for a file outside any intended scope, the sandbox still hands page content the `filepath` accessor, which returns the real, absolute filesystem path to that file, letting a template (or the same request, via straightforward means) resolve and read its bytes.
Proof of concept, verified, real output
I built a working harness against the real, unmodified source, not a reimplementation, by cloning the exact commits Grav's own `composer.lock` pins for every class involved: `getgrav/grav` itself, `rockettheme/toolbox` (for `UniformResourceLocator::isStream()`), `pimple/pimple` (the DI container `Grav\Common\Grav` extends), and `psr/container`.
Step 1, confirm the pinned commits used:
```
$ python3 -c "
import json
d = json.load(open('composer.lock'))
for name in ('rockettheme/toolbox','pimple/pimple','psr/container'):
for pkg in d['packages']:
if pkg['name'] == name:
print(name, pkg['source']['reference'])
"
rockettheme/toolbox c569a53304cd7d95ff21bffa6fc590adcf0be83d
pimple/pimple 8cfe7f74ac22a433d303914eba9ea4c2a834edce
psr/container c71ecc56dfe541dbd90c5360474fbc405f8d5963
```
Step 2, clone each at that exact commit:
```
$ git clone https://github.com/rockettheme/toolbox.git && cd toolbox && git checkout c569a53304cd7d95ff21bffa6fc590adcf0be83d
$ git clone https://github.com/silexphp/Pimple.git && cd Pimple && git checkout 8cfe7f74ac22a433d303914eba9ea4c2a834edce
$ git clone https://github.com/php-fig/container.git && cd container && git checkout c71ecc56dfe541dbd90c5360474fbc405f8d5963
```
Step 3, PoC script. It registers a real `Grav` container (the actual class, not a stub) with a real `UniformResourceLocator` that only has `user`/`image` streams registered, matching a normal site, no stream for arbitrary filesystem paths. It then calls the real, unmodified `Grav\Common\Page\Media` class exactly the way `mediaDirFunc()` does, on a plain filesystem path standing in for "some directory outside the intended scope" (I used a throwaway `/tmp` directory rather than a real system path, to keep the PoC harmless to run, the mechanism is identical for any path the web server user can read, `/etc`, another tenant's directory on shared hosting, Grav's own non-webroot folders, etc):
```php
mediaTypes = array_fill_keys(
['jpg','jpeg','png','gif','svg','txt','json','xml','pdf',…
Related
Alias · CVE (1)
- CVE-2026-72697 Grav CMS before 2.0.16 contains a path traversal vulnerability in the media_directory() Twig function that fails to validate filesystem… vulnerability
Weakness · CWE (1)
- CWE-22 Improper Limitation of a Pathname to a Restricted Directory weakness