GHSA-jq29-c7v8-rg55

Suggest an improvement
Source
https://github.com/advisories/GHSA-jq29-c7v8-rg55
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-jq29-c7v8-rg55/GHSA-jq29-c7v8-rg55.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-jq29-c7v8-rg55
Aliases
Published
2026-09-17T20:34:05Z
Modified
2026-09-17T20:45:05Z
Severity
  • 8.1 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H CVSS Calculator
  • 7.1 (High) CVSS_V4 - 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 CVSS Calculator
Summary
Grav: Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion
Details

Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion

Summary

A path traversal vulnerability in MediaUploadTrait::deleteFile() allows an authenticated user with media management permissions to delete arbitrary files on the server. The method validates only the basename portion of the filename using Utils::checkFilename(), while the directory path (which may contain ../ sequences) is preserved and passed unvalidated to unlink(). This enables directory escape from the intended media storage path.

Severity

High (8.1) - CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

CWE

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

Details

In system/src/Grav/Common/Media/Traits/MediaUploadTrait.php, the deleteFile() method (lines 332-365) performs filename validation only on the basename, not the full path:

public function deleteFile(string $filename, ?array $settings = null): void
{
    $settings = $this->getUploadSettings($settings);
    $filesystem = Filesystem::getInstance(false);

    // Line 339-340: Only the BASENAME is validated
    $basename = $filesystem->basename($filename);  // e.g. "evil.jpg" from "../../evil.jpg"
    if (!Utils::checkFilename($basename)) {         // passes - no traversal in basename
        throw new RuntimeException(/* ... */);
    }

    $path = $settings['destination'] ?? $this->getPath();
    // ...

    // Line 353: Full pathname (with traversal) is preserved
    $pathname = $filesystem->pathname($filename);   // "../../"

    // Line 356-357: Traversal path reconstructed
    [$base, $ext,,] = $this->getFileParts($basename);
    $name = "{$pathname}{$base}.{$ext}";            // "../../evil.jpg"

    // Line 360: Passed to doRemove()
    $this->doRemove($name, $path);
}

doRemove() (line 521-582) then calls:

// Line 538
unlink("{$folder}/{$filename}");
// e.g. unlink("/var/www/grav/user/pages/mypage/../../config/system.yaml")

Utils::checkFilename() (lines 1022-1044) properly checks for /, \, and .., but it is applied to $filesystem->basename($filename) (the last path component only), so traversal sequences in the directory portion are never validated.

Data flow from user input

The vulnerability is reachable through the Flex media handling pipeline:

  1. FlexMediaTrait::setUpdatedMedia() (line 386) iterates form flash data where $filename is the array key - user-controlled
  2. For file deletions ($file is null, line 396), NO upload validation is performed (the checkUploadedFile() call at line 401 only executes when $file is truthy)
  3. The raw filename is stored in $this->_uploads at line 414
  4. saveUpdatedMedia() (line 499) calls $media->deleteFile($filename, $settings) with the unsanitized filename

Sibling: renameFile()

The same pattern exists in renameFile() (lines 374-405) which has even weaker validation - it performs NO checkFilename() call at all. While renameFile() currently has no callers in the core codebase, it is part of the public MediaUploadInterface and should be fixed as defense-in-depth.

Proof of Concept

Environment: Grav CMS 2.0.16 with admin plugin

The attack requires an authenticated admin user with page/media editing permissions (not super-admin).

  1. Create a target file:
echo "DELETE_ME" > /var/www/grav/user/data/target.txt
  1. Submit a Flex object form (e.g. page edit) with a crafted media deletion where the filename key contains path traversal:
POST /admin/pages/mypage/task:save
Content-Type: multipart/form-data

# The form flash data includes a media deletion entry with key:
# "../../data/target.txt" -> null (deletion marker)
  1. When saveUpdatedMedia() processes the deletion queue:

    • $filename = ../../data/target.txt
    • deleteFile("../../data/target.txt") is called
    • $basename = target.txt (passes checkFilename())
    • $pathname = ../../data/
    • $name = ../../data/target.txt
    • doRemove() calls unlink("/var/www/grav/user/pages/mypage/../../data/target.txt")
    • Which resolves to unlink("/var/www/grav/user/data/target.txt")
  2. The file is deleted outside the intended media directory.

Impact

An authenticated user with media management permissions can:

  • Delete configuration files (user/config/system.yaml, user/config/security.yaml)
  • Delete other pages' content files
  • Delete authentication-related files (user account YAML files)
  • Cause denial of service by removing critical application files
  • Potentially escalate privileges by removing security configuration

Suggested Fix

Apply Utils::checkFilename() to the full $filename parameter before decomposing it, or reject any filename containing directory separators or .. sequences:

public function deleteFile(string $filename, ?array $settings = null): void
{
    $settings = $this->getUploadSettings($settings);
    $filesystem = Filesystem::getInstance(false);

    // Validate the FULL filename, not just the basename
    if (!Utils::checkFilename($filename)) {
        throw new RuntimeException(/* ... */);
    }

    // ... rest unchanged
}

The same fix should be applied to renameFile() for both $from and $to parameters.

References

  • Vulnerable file: system/src/Grav/Common/Media/Traits/MediaUploadTrait.php lines 332-365, 521-582
  • Caller: system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php lines 386-414, 490-499
  • Sibling: system/src/Grav/Common/Media/Traits/MediaUploadTrait.php lines 374-405 (renameFile)
  • Related GHSA: GHSA-g6j3-8jv9-ch5f (path traversal in PagesController::batchCopy - different file, same bug class)

Disclosure

This vulnerability was discovered using AI-assisted security research tools.

Database specific
{
    "cwe_ids":  [
        "CWE-22"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-09-17T20:34:05Z",
    "nvd_published_at":  null,
    "severity":  "HIGH"
}
References

Affected packages

Packagist / getgrav/grav

Package

Name
getgrav/grav
Purl
pkg:composer/getgrav/grav

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
2.0.16

Affected versions

0.*
0.8.0
0.9.0
0.9.1
0.9.2
0.9.3
0.9.4
0.9.5
0.9.6
0.9.7
0.9.8
0.9.9
0.9.10
0.9.11
0.9.12
0.9.13
0.9.14
0.9.15
0.9.16
0.9.17
0.9.18
0.9.19
0.9.20
0.9.21
0.9.22
0.9.23
0.9.24
0.9.25
0.9.26
0.9.27
0.9.28
0.9.29
0.9.30
0.9.31
0.9.32
0.9.33
0.9.34
0.9.35
0.9.36
0.9.37
0.9.38
0.9.39
0.9.40
0.9.41
0.9.42
0.9.43
0.9.44
0.9.45
1.*
1.0.0-rc.1
1.0.0-rc.2
1.0.0-rc.3
1.0.0-rc.4
1.0.0-rc.5
1.0.0-rc.6
1.0.0
1.0.1
1.0.2
1.0.3
1.0.4
1.0.5
1.0.6
1.0.7
1.0.8
1.0.9
1.0.10
1.1.0-beta.1
1.1.0-beta.2
1.1.0-beta.3
1.1.0-beta.4
1.1.0-beta.5
1.1.0-rc.1
1.1.0-rc.2
1.1.0-rc.3
1.1.0
1.1.1
1.1.2
1.1.3
1.1.4
1.1.5
1.1.6
1.1.7
1.1.8
1.1.9-rc.1
1.1.9-rc.2
1.1.9-rc.3
1.1.9
1.1.10
1.1.11
1.1.12
1.1.13
1.1.14
1.1.15
1.1.16
1.1.17
1.2.0-rc.1
1.2.0-rc.2
1.2.0-rc.3
1.2.0
1.2.1
1.2.2
1.2.3
1.2.4
1.3.0-rc.1
1.3.0-rc.2
1.3.0-rc.3
1.3.0-rc.4
1.3.0-rc.5
1.3.0
1.3.1
1.3.2
1.3.3
1.3.4
1.3.5
1.3.6
1.3.7
1.3.8
1.3.9
1.3.10
1.4.0-beta.1
1.4.0-beta.2
1.4.0-beta.3
1.4.0-rc.1
1.4.0-rc.2
1.4.0
1.4.1
1.4.2
1.4.3
1.4.4
1.4.5
1.4.6
1.4.7
1.4.8
1.5.0-beta.1
1.5.0-beta.2
1.5.0-rc.1
1.5.0
1.5.1
1.5.2
1.5.3
1.5.4
1.5.5
1.5.6
1.5.7
1.5.8
1.5.9
1.5.10
1.6.0-beta.1
1.6.0-beta.2
1.6.0-beta.3
1.6.0-beta.4
1.6.0-beta.5
1.6.0-beta.6
1.6.0-beta.7
1.6.0-beta.8
1.6.0-rc.1
1.6.0-rc.2
1.6.0-rc.3
1.6.0-rc.4
1.6.0
1.6.1
1.6.2
1.6.3
1.6.4
1.6.5
1.6.6
1.6.7
1.6.8
1.6.9
1.6.10
1.6.11
1.6.12
1.6.13
1.6.14
1.6.15
1.6.16
1.6.17
1.6.18
1.6.19
1.6.20
1.6.21
1.6.22
1.6.23
1.6.24
1.6.25
1.6.26
1.6.27
1.6.28
1.6.29
1.6.30
1.6.31
1.7.0-beta.1
1.7.0-beta.2
1.7.0-beta.3
1.7.0-beta.4
1.7.0-beta.5
1.7.0-beta.6
1.7.0-beta.7
1.7.0-beta.8
1.7.0-beta.9
1.7.0-beta.10
1.7.0-rc.1
1.7.0-rc.2
1.7.0-rc.3
1.7.0-rc.4
1.7.0-rc.5
1.7.0-rc.6
1.7.0-rc.7
1.7.0-rc.8
1.7.0-rc.9
1.7.0-rc.10
1.7.0-rc.11
1.7.0-rc.12
1.7.0-rc.13
1.7.0-rc.14
1.7.0-rc.15
1.7.0-rc.16
1.7.0-rc.17
1.7.0-rc.18
1.7.0-rc.19
1.7.0-rc.20
1.7.0
1.7.1
1.7.3
1.7.4
1.7.5
1.7.6
1.7.7
1.7.8
1.7.9
1.7.10
1.7.12
1.7.13
1.7.14
1.7.15
1.7.16
1.7.17
1.7.18
1.7.19
1.7.20
1.7.21
1.7.22
1.7.23
1.7.24
1.7.25
1.7.26
1.7.26.1
1.7.27
1.7.27.1
1.7.28
1.7.29
1.7.29.1
1.7.30
1.7.31
1.7.32
1.7.33
1.7.34
1.7.35
1.7.36
1.7.37
1.7.37.1
1.7.38
1.7.39
1.7.39.1
1.7.39.2
1.7.39.3
1.7.39.4
1.7.40
1.7.41
1.7.41.1
1.7.41.2
1.7.42
1.7.42.1
1.7.42.2
1.7.42.3
1.7.43
1.7.44
1.7.45
1.7.46
1.7.47
1.7.48
1.7.49
1.7.49.1
1.7.49.2
1.7.49.3
1.7.49.4
1.7.49.5
1.7.51
1.7.52
1.7.53
1.7.53.1
1.7.53.2
1.7.53.3
1.8.0-beta.1
1.8.0-beta.2
1.8.0-beta.3
1.8.0-beta.4
1.8.0-beta.5
1.8.0-beta.6
1.8.0-beta.7
1.8.0-beta.8
1.8.0-beta.9
1.8.0-beta.10
1.8.0-beta.11
1.8.0-beta.12
1.8.0-beta.13
1.8.0-beta.14
1.8.0-beta.15
1.8.0-beta.16
1.8.0-beta.17
1.8.0-beta.18
1.8.0-beta.19
1.8.0-beta.20
1.8.0-beta.21
1.8.0-beta.22
1.8.0-beta.23
1.8.0-beta.24
1.8.0-beta.25
1.8.0-beta.26
1.8.0-beta.27
1.8.0-beta.28
1.8.0-beta.29
2.*
2.0.0-beta.1
2.0.0-beta.2
2.0.0-beta.3
2.0.0-beta.4
2.0.0-rc.1
2.0.0-rc.2
2.0.0-rc.3
2.0.0-rc.4
2.0.0-rc.5
2.0.0-rc.6
2.0.0-rc.7
2.0.0-rc.8
2.0.0-rc.9
2.0.0-rc.10
2.0.0
2.0.1
2.0.2
2.0.3
2.0.4
2.0.5
2.0.6
2.0.7
2.0.8
2.0.9
2.0.10
2.0.11
2.0.12
2.0.13
2.0.14
2.0.15

Database specific

last_known_affected_version_range
"<= 2.0.15"
source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-jq29-c7v8-rg55/GHSA-jq29-c7v8-rg55.json"