Medium severity6.5GHSA Advisory· Published May 11, 2026· Updated May 12, 2026
CVE-2026-42610
CVE-2026-42610
Description
Grav is a file-based Web platform. Prior to 2.0.0-beta.2, a low-privileged user (EX: Content Editor with only pages.update permissions) can bypass the existing Twig sandbox restrictions by utilizing the grav['accounts'] service. Attacker can programmatically load administrative user objects and extract sensitive data, including Bcrypt password hashes and the security salt. This vulnerability is fixed in 2.0.0-beta.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
getgrav/gravPackagist | < 2.0.0-beta.2 | 2.0.0-beta.2 |
Affected products
1Patches
1c66dfeb5ff67Security fixes: cache + serialization integrity, git-clone shell escape
9 files changed · +524 −33
CHANGELOG.md+2 −0 modified@@ -13,6 +13,8 @@ * [security] Fixed unauthenticated path traversal in `FormFlash` (GHSA-hmcx-ch82-3fv2): `session_id` and `unique_id` now pass through a strict allowlist before being used to build on-disk paths, preventing arbitrary directory creation via the `__form-flash-id` parameter. * [security] Fixed salt disclosure via sandboxed Twig (GHSA-3f29-pqwf-v4j4): the HMAC key used for CSRF nonces and admin rate-limit hashing has moved out of Config into `user/config/security-private.php` (blocked from web access by the default `user/*.php` htaccess rule), so `{{ grav.config.get('security.salt') }}` no longer leaks it. Existing sites are migrated automatically on first request — existing nonces and sessions survive the upgrade. * [security] Hardened the new-user uniqueness guard in `UserObject::save` (GHSA-rr73-568v-28f8): `strpos(..., '@@')` replaced with `str_contains` (the old form was falsy when the transient-key marker was at position 0, silently bypassing the check) and the check now runs for every `FlexStorageInterface` backend rather than only `FileStorage`. A low-privileged user with `admin.users.create` can no longer disrupt a super-admin account by submitting the admin's username through the "add user" form. + * [security] Added HMAC integrity to `Framework\Cache\Adapter\FileCache` (GHSA-gwfr-jfjf-92vv): every cache payload is signed with `Security::getNonceKey()` on write and verified on read; tampered, attacker-planted, or pre-upgrade files are treated as cache misses and removed instead of being unserialized. The on-disk format is now versioned (`v2\n<expires>\n<key>\n<hmac>\n<serialized>`); existing caches rebuild transparently on first read. + * [security] Closed GHSA-vj3m-2g9h-vm4p (5-part advisory): (#1) `JobQueue` now HMAC-signs the `serialized_job` blob — a tampered queue file can no longer smuggle a forged `Job` for direct RCE via `Job::exec → call_user_func_array`; legitimate items still execute via the structured-fields fallback. (#3) `Session::getFlashObject` now wraps its payload in a versioned HMAC envelope, refusing to unserialize legacy/forged values. (#4) `InstallCommand` git-clone arguments (`branch`, `url`, `path` from `user/.dependencies`) now go through `escapeshellarg`, with a `--` separator before url/path to block option-injection. (#5) `twig_array_reduce` (plus `twig_array_some`/`twig_array_every`) added to `cleanDangerousTwig`'s callable blocklist alongside the existing `twig_array_map`/`filter`. (#2) was the same FileCache issue addressed by GHSA-gwfr above. # v2.0.0-beta.1 ## 04/16/2026
system/src/Grav/Common/Scheduler/JobQueue.php+30 −13 modified@@ -9,6 +9,7 @@ namespace Grav\Common\Scheduler; +use Grav\Common\Security; use RocketTheme\Toolbox\File\JsonFile; use RuntimeException; @@ -91,8 +92,13 @@ public function push(Job $job, string $priority = self::PRIORITY_NORMAL): string 'metadata' => [], ]; - // Always serialize the job to preserve its full state - $queueItem['serialized_job'] = base64_encode(serialize($job)); + // Always serialize the job to preserve its full state. The blob is HMAC-signed + // with the per-site nonce key so that a tampered queue file cannot be used to + // smuggle a forged Job (which would gain RCE via Job::exec → call_user_func_array). + // GHSA-vj3m-2g9h-vm4p (#1). + $serialized = serialize($job); + $queueItem['serialized_job'] = base64_encode($serialized); + $queueItem['serialized_job_hmac'] = hash_hmac('sha256', $serialized, Security::getNonceKey()); $this->writeQueueItem($queueItem, 'pending'); @@ -459,26 +465,37 @@ protected function getItemsInDirectory(string $directory): array */ protected function reconstructJob(array $item): ?Job { - if (isset($item['serialized_job'])) { - // Unserialize the job - try { - $job = unserialize(base64_decode((string) $item['serialized_job'])); - if ($job instanceof Job) { - return $job; + // GHSA-vj3m-2g9h-vm4p (#1): refuse to unserialize a queue item whose + // HMAC is missing or doesn't match — a tampered or attacker-planted + // queue file could otherwise inject a forged Job for direct RCE. + if (isset($item['serialized_job'], $item['serialized_job_hmac'])) { + $serialized = base64_decode((string) $item['serialized_job'], true); + if ($serialized !== false) { + $expected = hash_hmac('sha256', $serialized, Security::getNonceKey()); + if (hash_equals((string) $item['serialized_job_hmac'], $expected)) { + try { + $job = unserialize($serialized, ['allowed_classes' => true]); + if ($job instanceof Job) { + return $job; + } + } catch (\Exception) { + return null; + } } - } catch (\Exception) { - // Failed to unserialize - return null; } + // HMAC missing/mismatched/decode failed — fall through to the + // structured-fields rebuild below so legitimate queue items + // written before this fix still run, but without trusting their + // serialized state. } - + // Create a new job from command if (isset($item['command'])) { $args = $item['arguments'] ?? []; $job = new Job($item['command'], $args, $item['job_id']); return $job; } - + return null; }
system/src/Grav/Common/Security.php+6 −2 modified@@ -283,8 +283,12 @@ public static function getXssDefaults(): array * Property access (e.g. {{ page.header }}) is allowed; calls (header(...), obj.header(...), |header) are blocked. */ private const CALLABLE_DANGEROUS_NAMES = [ - // Twig internals - 'twig_array_map', 'twig_array_filter', 'call_user_func', 'call_user_func_array', + // Twig internals — every callback-taking helper. GHSA-vj3m-2g9h-vm4p (#5) + // called out the missing `twig_array_reduce`; adding the other Twig 3 + // callback predicates (some/every) at the same time as defense-in-depth. + 'twig_array_map', 'twig_array_filter', 'twig_array_reduce', + 'twig_array_some', 'twig_array_every', + 'call_user_func', 'call_user_func_array', 'forward_static_call', 'forward_static_call_array', // Twig environment manipulation 'registerUndefinedFunctionCallback', 'registerUndefinedFilterCallback',
system/src/Grav/Common/Session.php+27 −4 modified@@ -97,7 +97,11 @@ public function started() */ public function setFlashObject($name, mixed $object) { - $this->__set($name, serialize($object)); + // GHSA-vj3m-2g9h-vm4p (#3): wrap the serialized payload with an HMAC so a + // tampered session file can't smuggle in arbitrary class instantiation. + $serialized = serialize($object); + $hmac = hash_hmac('sha256', $serialized, Security::getNonceKey()); + $this->__set($name, "v2|{$hmac}|" . $serialized); return $this; } @@ -110,9 +114,28 @@ public function setFlashObject($name, mixed $object) */ public function getFlashObject($name) { - $serialized = $this->__get($name); - - $object = is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized; + $stored = $this->__get($name); + + $object = null; + if (is_string($stored) && str_starts_with($stored, 'v2|')) { + // 3-field format: v2|<hmac>|<serialized>. The serialized payload may + // itself contain `|`, so split with limit=3. + $parts = explode('|', $stored, 3); + if (count($parts) === 3) { + [, $expectedHmac, $serialized] = $parts; + $actualHmac = hash_hmac('sha256', $serialized, Security::getNonceKey()); + if (hash_equals($expectedHmac, $actualHmac)) { + try { + $object = unserialize($serialized, ['allowed_classes' => true]); + } catch (\Throwable) { + $object = null; + } + } + } + } elseif (!is_string($stored)) { + $object = $stored; + } + // Legacy unsigned strings or HMAC mismatches fall through with $object = null. $this->__unset($name);
system/src/Grav/Console/Cli/InstallCommand.php+13 −1 modified@@ -147,7 +147,19 @@ private function gitclone(): int foreach ($this->config['git'] as $repo => $data) { $path = $this->destination . DS . $data['path']; if (!file_exists($path)) { - exec('cd ' . escapeshellarg($this->destination) . ' && git clone -b ' . $data['branch'] . ' --depth 1 ' . $data['url'] . ' ' . $data['path'], $output, $return); + // GHSA-vj3m-2g9h-vm4p (#4): branch/url/path come from user/.dependencies + // and must be shell-escaped before reaching exec() — otherwise a planted + // .dependencies file gains command injection when an admin runs install. + // The bare `--` blocks option-injection in url/path positions + // (e.g. a `path` value like `--upload-pack=evil`). + $cmd = sprintf( + 'cd %s && git clone -b %s --depth 1 -- %s %s', + escapeshellarg($this->destination), + escapeshellarg((string) $data['branch']), + escapeshellarg((string) $data['url']), + escapeshellarg((string) $data['path']) + ); + exec($cmd, $output, $return); if (!$return) { $io->writeln('<green>SUCCESS</green> cloned <magenta>' . $data['url'] . '</magenta> -> <cyan>' . $path . '</cyan>');
system/src/Grav/Framework/Cache/Adapter/FileCache.php+46 −13 modified@@ -11,6 +11,7 @@ use ErrorException; use FilesystemIterator; +use Grav\Common\Security; use Grav\Framework\Cache\AbstractCache; use Grav\Framework\Cache\Exception\CacheException; use Grav\Framework\Cache\Exception\InvalidArgumentException; @@ -24,10 +25,20 @@ * * Defaults to 1 year TTL. Does not support unlimited TTL. * + * Cache files are HMAC-signed (sha256, key from Security::getNonceKey()) so that a + * tampered or attacker-planted file is rejected as a cache miss instead of + * being unserialized — closes GHSA-gwfr-jfjf-92vv. The on-disk format is: + * + * v2\n<expires>\n<key>\n<hmac-hex>\n<serialized> + * + * Pre-v2 files (no version line) are treated as cache misses and rebuilt. + * * @package Grav\Framework\Cache */ class FileCache extends AbstractCache { + private const FORMAT_VERSION = 'v2'; + /** @var string */ private $directory; /** @var string|null */ @@ -63,20 +74,38 @@ public function doGet($key, $miss) return $miss; } - if ($now >= (int) $expiresAt = fgets($h)) { + $version = rtrim((string)fgets($h)); + if ($version !== self::FORMAT_VERSION) { + // Pre-v2 file (or junk). Drop it; the caller will repopulate. fclose($h); @unlink($file); - } else { - $i = rawurldecode(rtrim((string)fgets($h))); - $value = stream_get_contents($h) ?: ''; + return $miss; + } + + if ($now >= (int) $expiresAt = fgets($h)) { fclose($h); + @unlink($file); + return $miss; + } - if ($i === $key) { - return unserialize($value, ['allowed_classes' => true]); - } + $i = rawurldecode(rtrim((string)fgets($h))); + $expectedHmac = rtrim((string)fgets($h)); + $value = stream_get_contents($h) ?: ''; + fclose($h); + + if ($i !== $key) { + return $miss; } - return $miss; + $actualHmac = hash_hmac('sha256', $value, Security::getNonceKey()); + if (!hash_equals($expectedHmac, $actualHmac)) { + // Tampered or stale-secret payload — refuse to unserialize and + // delete the file so it gets rebuilt cleanly next time. + @unlink($file); + return $miss; + } + + return unserialize($value, ['allowed_classes' => true]); } /** @@ -86,12 +115,16 @@ public function doGet($key, $miss) public function doSet($key, $value, $ttl) { $expiresAt = time() + (int)$ttl; + $serialized = serialize($value); + $hmac = hash_hmac('sha256', $serialized, Security::getNonceKey()); + + $payload = self::FORMAT_VERSION . "\n" + . $expiresAt . "\n" + . rawurlencode($key) . "\n" + . $hmac . "\n" + . $serialized; - $result = $this->write( - $this->getFile($key, true), - $expiresAt . "\n" . rawurlencode($key) . "\n" . serialize($value), - $expiresAt - ); + $result = $this->write($this->getFile($key, true), $payload, $expiresAt); if (!$result && !is_writable($this->directory)) { throw new CacheException(sprintf('Cache directory is not writable (%s)', $this->directory));
tests/unit/Grav/Common/Security/CleanDangerousTwigTest.php+5 −0 modified@@ -366,6 +366,11 @@ public static function providerCallbackFunctions(): array ['{{ array_map("system", ["id"]) }}', 'array_map with callback'], ['{{ array_filter(arr, "system") }}', 'array_filter with callback'], ['{{ usort(arr, "system") }}', 'usort with callback'], + // GHSA-vj3m-2g9h-vm4p (#5): twig_array_reduce was missing from the + // blocklist alongside its already-listed twig_array_map/filter siblings. + ['{{ twig_array_reduce(arr, "system", "") }}', 'twig_array_reduce GHSA-vj3m'], + ['{{ twig_array_some(arr, "system") }}', 'twig_array_some'], + ['{{ twig_array_every(arr, "system") }}', 'twig_array_every'], ]; }
tests/unit/Grav/Common/Security/FileCacheSecurityTest.php+163 −0 added@@ -0,0 +1,163 @@ +<?php + +use Codeception\Util\Fixtures; +use Grav\Common\Grav; +use Grav\Common\Security; +use Grav\Framework\Cache\Adapter\FileCache; + +/** + * Class FileCacheSecurityTest + * + * Covers: GHSA-gwfr-jfjf-92vv (insecure deserialization in FileCache). + * + * Verifies the HMAC-integrity wrapper around FileCache's on-disk payloads: + * - round-trip with the same key works, + * - a tampered payload is treated as a cache miss and the file is removed, + * - a pre-v2 file (no version line) is treated as a cache miss and removed, + * - a payload signed with a different key is rejected. + * + * Naming convention: test{Method}_{GHSA_ID}_{description} + */ +class FileCacheSecurityTest extends \PHPUnit\Framework\TestCase +{ + /** @var Grav */ + protected $grav; + + /** @var string */ + protected $cacheRoot; + + protected function setUp(): void + { + parent::setUp(); + $grav = Fixtures::get('grav'); + $this->grav = $grav(); + + $this->cacheRoot = sys_get_temp_dir() . '/grav-filecache-sec-' . bin2hex(random_bytes(4)); + @mkdir($this->cacheRoot, 0777, true); + } + + protected function tearDown(): void + { + $this->rrmdir($this->cacheRoot); + parent::tearDown(); + } + + private function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = "{$dir}/{$entry}"; + is_dir($path) ? $this->rrmdir($path) : @unlink($path); + } + @rmdir($dir); + } + + private function newCache(): FileCache + { + return new FileCache('test', 60, $this->cacheRoot); + } + + private function findCacheFile(): ?string + { + $iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->cacheRoot, FilesystemIterator::SKIP_DOTS)); + foreach ($iter as $file) { + if ($file->isFile()) { + return (string)$file; + } + } + return null; + } + + // ========================================================================= + // GHSA-gwfr-jfjf-92vv: HMAC integrity on file cache payloads + // ========================================================================= + + public function testGetSet_GHSAgwfr_RoundTripPreservesValue(): void + { + $cache = $this->newCache(); + $cache->set('alpha', ['hello' => 'world', 'n' => 42]); + + self::assertSame(['hello' => 'world', 'n' => 42], $cache->get('alpha')); + } + + public function testGet_GHSAgwfr_RejectsTamperedPayload(): void + { + $cache = $this->newCache(); + $cache->set('alpha', 'original-value'); + + $file = $this->findCacheFile(); + self::assertNotNull($file, 'cache file should exist after set'); + + // Flip a byte inside the serialized payload (last segment after the 4 + // header lines: v2, expires, key, hmac). + $contents = file_get_contents($file); + $lines = explode("\n", $contents, 5); + self::assertCount(5, $lines, 'cache file must have 5 segments'); + $lines[4] = str_replace('original-value', 'taintednvalue', $lines[4]); + file_put_contents($file, implode("\n", $lines)); + + $miss = '__MISS__'; + self::assertSame($miss, $cache->get('alpha', $miss), 'tampered file must be a miss'); + self::assertFileDoesNotExist($file, 'tampered file must be deleted'); + } + + public function testGet_GHSAgwfr_RejectsForgedHmacWithDifferentKey(): void + { + $cache = $this->newCache(); + $file = $cache->set('alpha', 'real-value'); + + // Reuse the file path. Hand-craft a payload whose HMAC was computed + // with the wrong key — exactly what an attacker who can write to the + // cache directory but cannot read user/config/security-private.php + // would produce. + $file = $this->findCacheFile(); + $serialized = serialize('attacker-payload'); + $forgedHmac = hash_hmac('sha256', $serialized, 'wrong-key-attacker-guessed'); + $payload = "v2\n" . (time() + 60) . "\n" . rawurlencode('alpha') . "\n" . $forgedHmac . "\n" . $serialized; + file_put_contents($file, $payload); + + $miss = '__MISS__'; + self::assertSame($miss, $cache->get('alpha', $miss), 'forged HMAC must be a miss'); + self::assertFileDoesNotExist($file, 'forged file must be deleted'); + } + + public function testGet_GHSAgwfr_RejectsPreV2FormatFile(): void + { + // Mimic the legacy file format: <expires>\n<key>\n<serialized> + // No version line, no HMAC. Pre-upgrade caches end up here. + $cache = $this->newCache(); + $cache->set('alpha', 'placeholder'); // create the file so getFile() path exists + + $file = $this->findCacheFile(); + self::assertNotNull($file); + $legacy = (time() + 60) . "\n" . rawurlencode('alpha') . "\n" . serialize('legacy-value'); + file_put_contents($file, $legacy); + + $miss = '__MISS__'; + self::assertSame($miss, $cache->get('alpha', $miss), 'pre-v2 file must be a miss'); + self::assertFileDoesNotExist($file, 'pre-v2 file must be deleted'); + } + + public function testGet_GHSAgwfr_RejectsKeyMismatchInPayload(): void + { + // The on-disk key field is part of the existing collision check; a + // valid HMAC over a payload whose key field doesn't match what we + // asked for must NOT be returned to the caller. + $cache = $this->newCache(); + $cache->set('alpha', 'a-value'); + $file = $this->findCacheFile(); + + $serialized = serialize('a-value'); + $hmac = hash_hmac('sha256', $serialized, Security::getNonceKey()); + $payload = "v2\n" . (time() + 60) . "\n" . rawurlencode('beta') . "\n" . $hmac . "\n" . $serialized; + file_put_contents($file, $payload); + + $miss = '__MISS__'; + self::assertSame($miss, $cache->get('alpha', $miss), 'key-field mismatch must be a miss'); + } +}
tests/unit/Grav/Common/Security/UnserializeIntegritySecurityTest.php+232 −0 added@@ -0,0 +1,232 @@ +<?php + +use Codeception\Util\Fixtures; +use Grav\Common\Grav; +use Grav\Common\Security; +use Grav\Common\Scheduler\Job; +use Grav\Common\Scheduler\JobQueue; + +/** + * Class UnserializeIntegritySecurityTest + * + * Covers: GHSA-vj3m-2g9h-vm4p (#1 JobQueue, #3 Session) — HMAC integrity + * around `unserialize(..., ['allowed_classes' => true])` sinks so that a + * tampered payload cannot smuggle in arbitrary class instantiation. + * + * Note: the FileCache half of this advisory (#2) has its own dedicated + * test in FileCacheSecurityTest. + * + * Naming convention: test{Method}_{GHSA_ID}_{description} + */ +class UnserializeIntegritySecurityTest extends \PHPUnit\Framework\TestCase +{ + /** @var Grav */ + protected $grav; + + /** @var string */ + protected $queueDir; + + protected function setUp(): void + { + parent::setUp(); + $grav = Fixtures::get('grav'); + $this->grav = $grav(); + + $this->queueDir = sys_get_temp_dir() . '/grav-jobqueue-sec-' . bin2hex(random_bytes(4)); + @mkdir($this->queueDir, 0777, true); + } + + protected function tearDown(): void + { + if (is_dir($this->queueDir)) { + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->queueDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iter as $f) { + $f->isDir() ? @rmdir((string)$f) : @unlink((string)$f); + } + @rmdir($this->queueDir); + } + parent::tearDown(); + } + + // ========================================================================= + // GHSA-vj3m-2g9h-vm4p (#1): JobQueue serialized_job HMAC integrity + // ========================================================================= + + /** + * Drive `JobQueue::reconstructJob` with a hand-built queue item; the + * method is protected, so step through it with reflection. + */ + private function reconstruct(JobQueue $queue, array $item): ?Job + { + $m = (new ReflectionClass($queue))->getMethod('reconstructJob'); + $m->setAccessible(true); + return $m->invoke($queue, $item); + } + + public function testReconstructJob_GHSAvj3m_RoundTripsValidHmacSignedJob(): void + { + $queue = new JobQueue($this->queueDir); + + $job = new Job('echo', ['ok'], 'job-1'); + $serialized = serialize($job); + $item = [ + 'serialized_job' => base64_encode($serialized), + 'serialized_job_hmac' => hash_hmac('sha256', $serialized, Security::getNonceKey()), + 'job_id' => 'job-1', + ]; + + $reconstructed = $this->reconstruct($queue, $item); + self::assertInstanceOf(Job::class, $reconstructed); + self::assertSame('job-1', $reconstructed->getId()); + } + + public function testReconstructJob_GHSAvj3m_RejectsForgedSerializedJob(): void + { + $queue = new JobQueue($this->queueDir); + + // Attacker constructs a Job with `command='system'` and signs it with + // their guessed key. With HMAC verification, the forged blob is + // rejected and we fall through to the structured-fields rebuild. + $forgedJob = new Job('system', ['rm -rf /'], 'pwn'); + $forgedSerialized = serialize($forgedJob); + $item = [ + 'serialized_job' => base64_encode($forgedSerialized), + 'serialized_job_hmac' => hash_hmac('sha256', $forgedSerialized, 'attacker-key-guess'), + 'job_id' => 'job-2', + // Legitimate fallback fields the queue would normally have. + 'command' => 'echo', + 'arguments' => ['safe'], + ]; + + $reconstructed = $this->reconstruct($queue, $item); + self::assertInstanceOf(Job::class, $reconstructed); + self::assertNotSame('system', $reconstructed->getCommand(), 'forged command must not survive'); + self::assertSame('echo', $reconstructed->getCommand(), 'must rebuild from structured fallback fields'); + } + + public function testReconstructJob_GHSAvj3m_RejectsItemMissingHmacField(): void + { + $queue = new JobQueue($this->queueDir); + + // Pre-fix queue files only carried `serialized_job`; with the fix in + // place those entries can no longer trigger unserialize, but if a + // structured fallback exists they still execute via that path. + $forgedJob = new Job('system', ['rm -rf /'], 'pwn'); + $item = [ + 'serialized_job' => base64_encode(serialize($forgedJob)), + 'job_id' => 'job-3', + 'command' => 'echo', + 'arguments' => ['safe'], + ]; + + $reconstructed = $this->reconstruct($queue, $item); + self::assertInstanceOf(Job::class, $reconstructed); + self::assertSame('echo', $reconstructed->getCommand()); + } + + public function testReconstructJob_GHSAvj3m_ReturnsNullOnFullyTamperedItem(): void + { + $queue = new JobQueue($this->queueDir); + + // No HMAC, no fallback fields — nothing to safely reconstruct from. + $item = [ + 'serialized_job' => base64_encode(serialize(new Job('system', ['x'], 'p'))), + 'job_id' => 'job-4', + ]; + + self::assertNull($this->reconstruct($queue, $item)); + } + + // ========================================================================= + // GHSA-vj3m-2g9h-vm4p (#3): Session::getFlashObject HMAC integrity + // ========================================================================= + // + // We can't easily exercise Session through its full PHP-session lifecycle + // in a unit test, so we verify the wire format directly: setFlashObject + // produces a `v2|<hmac>|<serialized>` envelope, and getFlashObject only + // accepts payloads whose HMAC verifies against Security::getNonceKey(). + + public function testSetFlashObject_GHSAvj3m_WrapsPayloadWithVersionedHmacEnvelope(): void + { + $session = $this->newSessionStub(); + $session->setFlashObject('payload', ['hello' => 'world']); + + $stored = $session->_storage['payload'] ?? null; + self::assertIsString($stored); + self::assertStringStartsWith('v2|', $stored); + + $parts = explode('|', $stored, 3); + self::assertCount(3, $parts); + [, $hmac, $serialized] = $parts; + self::assertSame( + hash_hmac('sha256', $serialized, Security::getNonceKey()), + $hmac, + 'envelope HMAC must match Security::getNonceKey()' + ); + self::assertSame(['hello' => 'world'], unserialize($serialized, ['allowed_classes' => false])); + } + + public function testGetFlashObject_GHSAvj3m_RoundTripsValidValue(): void + { + $session = $this->newSessionStub(); + $session->setFlashObject('payload', ['hello' => 'world']); + + self::assertSame(['hello' => 'world'], $session->getFlashObject('payload')); + } + + public function testGetFlashObject_GHSAvj3m_RejectsLegacyUnsignedPayload(): void + { + $session = $this->newSessionStub(); + // Pre-fix wire format: a bare serialize() blob with no envelope. + $session->_storage['payload'] = serialize(['hello' => 'world']); + + self::assertNull($session->getFlashObject('payload'), 'legacy unsigned payload must not be unserialized'); + } + + public function testGetFlashObject_GHSAvj3m_RejectsForgedHmac(): void + { + $session = $this->newSessionStub(); + $serialized = serialize(['attacker' => 'payload']); + $forgedHmac = hash_hmac('sha256', $serialized, 'wrong-key'); + $session->_storage['payload'] = "v2|{$forgedHmac}|{$serialized}"; + + self::assertNull($session->getFlashObject('payload'), 'forged HMAC must be rejected'); + } + + /** + * Minimal stub that mimics the bits of Grav\Common\Session that + * setFlashObject/getFlashObject touch (just `__get`/`__set`/`__unset` + * over an in-memory array). Avoids booting PHP session machinery. + */ + private function newSessionStub(): object + { + return new class extends \Grav\Common\Session { + public array $_storage = []; + + public function __construct() {} + + public function __set($name, $value): void + { + $this->_storage[$name] = $value; + } + + public function __get($name) + { + return $this->_storage[$name] ?? null; + } + + public function __unset($name): void + { + unset($this->_storage[$name]); + } + + public function __isset($name): bool + { + return isset($this->_storage[$name]); + } + }; + } +}
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/getgrav/grav/commit/c66dfeb5ff679a1667678c6335eb9ff3255dfc47nvdPatchWEB
- github.com/getgrav/grav/security/advisories/GHSA-3f29-pqwf-v4j4nvdExploitPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-3f29-pqwf-v4j4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-42610ghsaADVISORY
News mentions
0No linked articles in our index yet.