Grav is vulnerable to Arbitrary File Read
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.
| Package | Affected versions | Patched versions |
|---|---|---|
getgrav/gravPackagist | < 1.8.0-beta.27 | 1.8.0-beta.27 |
Affected products
1Patches
1ed640a13143cMerge branch 'fix/GHSA-p4ww-mcp9-j6f2-GHSA-m8vh-v6r6-w7p6-GHSA-j422-qmxp-hv94-file-path-security' into 1.8
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- github.com/advisories/GHSA-p4ww-mcp9-j6f2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-66300ghsaADVISORY
- github.com/getgrav/grav/commit/ed640a13143c4177af013cf001969ed2c5e197eeghsax_refsource_MISCWEB
- github.com/getgrav/grav/security/advisories/GHSA-p4ww-mcp9-j6f2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.