VYPR
Moderate severityNVD Advisory· Published Dec 1, 2025· Updated Dec 3, 2025

Grav vulnerable to Path Traversal allowing server files backup

CVE-2025-66302

Description

Grav is a file-based Web platform. Prior to 1.8.0-beta.27, A path traversal vulnerability has been identified in Grav CMS, allowing authenticated attackers with administrative privileges to read arbitrary files on the underlying server filesystem. This vulnerability arises due to insufficient input sanitization in the backup tool, where user-supplied paths are not properly restricted, enabling access to files outside the intended webroot directory. The impact of this vulnerability depends on the privileges of the user account running the application. This vulnerability is fixed in 1.8.0-beta.27.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
getgrav/gravPackagist
< 1.8.0-beta.271.8.0-beta.27

Affected products

1

Patches

1
ed640a13143c

Merge branch 'fix/GHSA-p4ww-mcp9-j6f2-GHSA-m8vh-v6r6-w7p6-GHSA-j422-qmxp-hv94-file-path-security' into 1.8

https://github.com/getgrav/gravAndy MillerNov 30, 2025via ghsa
4 files changed · +293 5
  • system/src/Grav/Common/Backup/Backups.php+19 0 modified
    @@ -225,6 +225,25 @@ public static function backup($id = 0, ?callable $status = null)
                 throw new RuntimeException("Backup location: {$backup_root} does not exist...");
             }
     
    +        // Security: Resolve real path and ensure it's within GRAV_ROOT to prevent path traversal
    +        $realBackupRoot = realpath($backup_root);
    +        $realGravRoot = realpath(GRAV_ROOT);
    +
    +        if ($realBackupRoot === false || $realGravRoot === false) {
    +            throw new RuntimeException("Invalid backup location: {$backup_root}");
    +        }
    +
    +        // Ensure the backup root is within GRAV_ROOT or a parent thereof (for backing up GRAV itself)
    +        // Block access to system directories outside the web root
    +        $blockedPaths = ['/etc', '/root', '/home', '/var', '/usr', '/bin', '/sbin', '/tmp', '/proc', '/sys', '/dev'];
    +        foreach ($blockedPaths as $blocked) {
    +            if (strpos($realBackupRoot, $blocked) === 0) {
    +                throw new RuntimeException("Backup location not allowed: {$backup_root}");
    +            }
    +        }
    +
    +        $backup_root = $realBackupRoot;
    +
             $options = [
                 'exclude_files' => static::convertExclude($backup->exclude_files ?? ''),
                 'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''),
    
  • system/src/Grav/Common/Language/Language.php+13 2 modified
    @@ -134,7 +134,17 @@ public function getLanguages()
          */
         public function setLanguages($langs)
         {
    -        $this->languages = $langs;
    +        // Validate and sanitize language codes to prevent regex injection
    +        $validLangs = [];
    +        foreach ((array)$langs as $lang) {
    +            $lang = (string)$lang;
    +            // Only allow valid language codes (alphanumeric, hyphens, underscores)
    +            // Examples: en, en-US, en_US, zh-Hans, pt-BR
    +            if (preg_match('/^[a-zA-Z]{2,3}(?:[-_][a-zA-Z0-9]{2,8})?$/', $lang)) {
    +                $validLangs[] = $lang;
    +            }
    +        }
    +        $this->languages = $validLangs;
     
             $this->init();
         }
    @@ -234,7 +244,8 @@ public function setActive($lang)
          */
         public function setActiveFromUri($uri)
         {
    -        $regex = '/(^\/(' . $this->getAvailable() . '))(?:\/|\?|$)/i';
    +        // Pass delimiter '/' to getAvailable() to properly escape language codes for regex
    +        $regex = '/(^\/(' . $this->getAvailable('/') . '))(?:\/|\?|$)/i';
     
             // if languages set
             if ($this->enabled()) {
    
  • system/src/Grav/Common/Twig/Extension/GravExtension.php+34 3 modified
    @@ -1438,11 +1438,42 @@ public function readFileFunc($filepath)
                 $filepath = $locator->findResource($filepath);
             }
     
    -        if ($filepath && file_exists($filepath)) {
    -            return file_get_contents($filepath);
    +        if (!$filepath || !file_exists($filepath)) {
    +            return false;
             }
     
    -        return false;
    +        // Security: Get the real path to prevent path traversal
    +        $realpath = realpath($filepath);
    +        if ($realpath === false) {
    +            return false;
    +        }
    +
    +        // Security: Ensure the file is within GRAV_ROOT
    +        $gravRoot = realpath(GRAV_ROOT);
    +        if ($gravRoot === false || strpos($realpath, $gravRoot) !== 0) {
    +            return false;
    +        }
    +
    +        // Security: Block access to sensitive files and directories
    +        $blockedPatterns = [
    +            '/\/accounts\/[^\/]+\.yaml$/',      // User account files
    +            '/\/config\/security\.yaml$/',       // Security config
    +            '/\/\.env/',                         // Environment files
    +            '/\/\.git/',                         // Git directory
    +            '/\/\.htaccess/',                    // Apache config
    +            '/\/\.htpasswd/',                    // Apache passwords
    +            '/\/vendor\//',                      // Composer vendor (may contain sensitive info)
    +            '/\/logs\//',                        // Log files
    +            '/\/backup\//',                      // Backup files
    +        ];
    +
    +        foreach ($blockedPatterns as $pattern) {
    +            if (preg_match($pattern, $realpath)) {
    +                return false;
    +            }
    +        }
    +
    +        return file_get_contents($realpath);
         }
     
         /**
    
  • tests/unit/Grav/Common/Security/FilePathSecurityTest.php+227 0 added
    @@ -0,0 +1,227 @@
    +<?php
    +
    +use Codeception\Util\Fixtures;
    +use Grav\Common\Grav;
    +use Grav\Common\Language\Language;
    +
    +/**
    + * Class FilePathSecurityTest
    + *
    + * Tests for file path and language security fixes.
    + * Covers: GHSA-p4ww-mcp9-j6f2 (file read), GHSA-m8vh-v6r6-w7p6 (language DoS), GHSA-j422-qmxp-hv94 (backup traversal)
    + *
    + * Naming convention: test{Method}_{GHSA_ID}_{description}
    + */
    +class FilePathSecurityTest extends \PHPUnit\Framework\TestCase
    +{
    +    /** @var Grav */
    +    protected $grav;
    +
    +    /** @var Language */
    +    protected $language;
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +        $grav = Fixtures::get('grav');
    +        $this->grav = $grav();
    +        $this->language = new Language($this->grav);
    +    }
    +
    +    // =========================================================================
    +    // GHSA-m8vh-v6r6-w7p6: DoS via Language Regex Injection
    +    // =========================================================================
    +
    +    /**
    +     * @dataProvider providerGHSAm8vh_InvalidLanguageCodes
    +     */
    +    public function testSetLanguages_GHSAm8vh_FiltersInvalidLanguageCodes(string $lang, string $description): void
    +    {
    +        $this->language->setLanguages([$lang]);
    +        $languages = $this->language->getLanguages();
    +
    +        self::assertNotContains($lang, $languages, "Should filter invalid language code: $description");
    +    }
    +
    +    public static function providerGHSAm8vh_InvalidLanguageCodes(): array
    +    {
    +        return [
    +            // Regex injection attempts
    +            ["'", 'Single quote (regex breaker)'],
    +            ['"', 'Double quote'],
    +            ['/', 'Forward slash (regex delimiter)'],
    +            ['\\', 'Backslash'],
    +            ['()', 'Empty parentheses'],
    +            ['.*', 'Regex wildcard'],
    +            ['.+', 'Regex one-or-more'],
    +            ['[a-z]', 'Regex character class'],
    +            ['en|rm -rf', 'Pipe with command'],
    +            ['(?=)', 'Regex lookahead'],
    +
    +            // Path traversal in language
    +            ['../en', 'Path traversal'],
    +            ['en/../../etc', 'Nested path traversal'],
    +
    +            // Special characters
    +            ['en;', 'Semicolon'],
    +            ['en<script>', 'HTML tag'],
    +            ['en${PATH}', 'Shell variable'],
    +            ["en\nfr", 'Newline injection'],
    +            ["en\0fr", 'Null byte injection'],
    +
    +            // Too short/long
    +            ['e', 'Single character (too short)'],
    +            ['englishlanguage', 'Too long without separator'],
    +
    +            // Invalid format
    +            ['123', 'All numbers'],
    +            ['en-', 'Trailing hyphen'],
    +            ['-en', 'Leading hyphen'],
    +            ['en--US', 'Double hyphen'],
    +            ['en_', 'Trailing underscore'],
    +            ['_en', 'Leading underscore'],
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider providerGHSAm8vh_ValidLanguageCodes
    +     */
    +    public function testSetLanguages_GHSAm8vh_AllowsValidLanguageCodes(string $lang, string $description): void
    +    {
    +        $this->language->setLanguages([$lang]);
    +        $languages = $this->language->getLanguages();
    +
    +        self::assertContains($lang, $languages, "Should allow valid language code: $description");
    +    }
    +
    +    public static function providerGHSAm8vh_ValidLanguageCodes(): array
    +    {
    +        return [
    +            // Standard ISO 639-1 codes
    +            ['en', 'English'],
    +            ['fr', 'French'],
    +            ['de', 'German'],
    +            ['es', 'Spanish'],
    +            ['zh', 'Chinese'],
    +            ['ja', 'Japanese'],
    +            ['ru', 'Russian'],
    +            ['ar', 'Arabic'],
    +            ['pt', 'Portuguese'],
    +            ['it', 'Italian'],
    +
    +            // ISO 639-2 three-letter codes
    +            ['eng', 'English (3-letter)'],
    +            ['fra', 'French (3-letter)'],
    +            ['deu', 'German (3-letter)'],
    +
    +            // Language with region (hyphen)
    +            ['en-US', 'English (US)'],
    +            ['en-GB', 'English (UK)'],
    +            ['pt-BR', 'Portuguese (Brazil)'],
    +            ['zh-CN', 'Chinese (Simplified)'],
    +            ['zh-TW', 'Chinese (Traditional)'],
    +
    +            // Language with region (underscore)
    +            ['en_US', 'English (US) underscore'],
    +            ['pt_BR', 'Portuguese (Brazil) underscore'],
    +
    +            // Extended subtags
    +            ['zh-Hans', 'Chinese Simplified script'],
    +            ['zh-Hant', 'Chinese Traditional script'],
    +            ['sr-Latn', 'Serbian Latin script'],
    +            ['sr-Cyrl', 'Serbian Cyrillic script'],
    +        ];
    +    }
    +
    +    public function testSetLanguages_GHSAm8vh_FiltersMultipleMixedCodes(): void
    +    {
    +        $input = ['en', '../etc', 'fr', '.*', 'de-DE', 'invalid!', 'es'];
    +        $this->language->setLanguages($input);
    +        $languages = $this->language->getLanguages();
    +
    +        self::assertContains('en', $languages);
    +        self::assertContains('fr', $languages);
    +        self::assertContains('de-DE', $languages);
    +        self::assertContains('es', $languages);
    +
    +        self::assertNotContains('../etc', $languages);
    +        self::assertNotContains('.*', $languages);
    +        self::assertNotContains('invalid!', $languages);
    +    }
    +
    +    public function testSetLanguages_GHSAm8vh_HandlesEmptyArray(): void
    +    {
    +        $this->language->setLanguages([]);
    +        $languages = $this->language->getLanguages();
    +
    +        self::assertIsArray($languages);
    +        self::assertEmpty($languages);
    +    }
    +
    +    public function testSetLanguages_GHSAm8vh_HandlesNumericValues(): void
    +    {
    +        // Numeric values cast to string should be filtered as invalid
    +        $this->language->setLanguages([123, 456]);
    +        $languages = $this->language->getLanguages();
    +
    +        // Numeric strings are not valid language codes
    +        self::assertNotContains('123', $languages);
    +        self::assertNotContains('456', $languages);
    +    }
    +
    +    // =========================================================================
    +    // GHSA-m8vh-v6r6-w7p6: Regex Delimiter Escaping Test
    +    // =========================================================================
    +
    +    public function testGetAvailable_GHSAm8vh_ProperlyEscapesForRegex(): void
    +    {
    +        // Set some valid languages
    +        $this->language->setLanguages(['en', 'fr', 'de']);
    +
    +        // Get with regex delimiter - should be properly escaped
    +        $available = $this->language->getAvailable('/');
    +
    +        // The result should be usable in a regex without breaking
    +        $pattern = '/^(' . $available . ')$/';
    +
    +        // This should not throw a preg error
    +        $result = @preg_match($pattern, 'en');
    +        self::assertNotFalse($result, 'Pattern should be valid regex');
    +        self::assertEquals(1, $result, 'Pattern should match "en"');
    +    }
    +
    +    // =========================================================================
    +    // Edge Cases
    +    // =========================================================================
    +
    +    public function testSetLanguages_EdgeCase_CaseSensitivity(): void
    +    {
    +        // Language codes should preserve case
    +        $this->language->setLanguages(['EN', 'Fr', 'de-DE', 'PT-br']);
    +        $languages = $this->language->getLanguages();
    +
    +        self::assertContains('EN', $languages);
    +        self::assertContains('Fr', $languages);
    +        self::assertContains('de-DE', $languages);
    +        self::assertContains('PT-br', $languages);
    +    }
    +
    +    public function testSetLanguages_EdgeCase_MaxLength(): void
    +    {
    +        // Test boundary of valid length (2-3 for language, up to 8 for region)
    +        $this->language->setLanguages(['ab', 'abc', 'ab-12345678', 'abc-12345678']);
    +        $languages = $this->language->getLanguages();
    +
    +        self::assertContains('ab', $languages);
    +        self::assertContains('abc', $languages);
    +        self::assertContains('ab-12345678', $languages);
    +        self::assertContains('abc-12345678', $languages);
    +
    +        // These should be too long
    +        $this->language->setLanguages(['abcd', 'ab-123456789']);
    +        $languages = $this->language->getLanguages();
    +
    +        self::assertNotContains('abcd', $languages, '4-letter language code should be invalid');
    +        self::assertNotContains('ab-123456789', $languages, '9-char region should be invalid');
    +    }
    +}
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.