VYPR
High severityNVD Advisory· Published Dec 1, 2025· Updated Dec 2, 2025

Grav is vulnerable to Arbitrary File Read

CVE-2025-66300

Description

Grav is a file-based Web platform. Prior to 1.8.0-beta.27, A low privilege user account with page editing privilege can read any server files using "Frontmatter" form. This includes Grav user account files (/grav/user/accounts/*.yaml), which store hashed user password, 2FA secret, and the password reset token. This can allow an adversary to compromise any registered account by resetting a password for a user to get access to the password reset token from the file or by cracking the hashed password. 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.