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 Published 2026-09-17 Modified 2026-09-17 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

References