VulnWatch VulnWatch
← Back to dashboard
High github · GHSA-47ch-6w46-6xm7

Grav: media_directory() Twig function allows filesystem path traversal and file content disclosure from sandboxed page content

Published Sep 17, 2026 CVSS 6.5

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():

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:

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:

    # ...
    # 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):

Affected AI Products

claude
Get the weekly digest. Every Monday: top AI security stories of the week. Free.