Skip to content

Commit c66dfeb

Browse files
committed
Security fixes: cache + serialization integrity, git-clone shell escape
Closes the rest of the Tier-1 unauth/authz advisories from the 2026-04 batch: - GHSA-gwfr-jfjf-92vv: Framework\Cache\Adapter\FileCache now HMAC-signs every payload (sha256, key from Security::getNonceKey()) and verifies on read. Tampered, forged, or pre-upgrade files are treated as cache misses and unlinked instead of being unserialized. New on-disk format v2\n<expires>\n<key>\n<hmac>\n<serialized>; existing caches rebuild transparently. (Adapter isn't currently in Grav's main cache path — Symfony's FilesystemAdapter is — but the class is reachable to plugin authors so the hardening is defensive.) - GHSA-vj3m-2g9h-vm4p (5-part advisory): * #1 Scheduler\JobQueue: serialized_job blob now carries a sibling serialized_job_hmac field; reconstructJob refuses to unserialize an item whose HMAC missing/mismatches and falls through to the safe structured-fields rebuild. Closes the Job::exec → call_user_func_array direct RCE gadget chain. * #2 FileCache: same fix as GHSA-gwfr above. * #3 Session::getFlashObject: payload is now wrapped in "v2|<hmac>|<serialized>"; legacy/forged envelopes return null instead of triggering unserialize. * #4 InstallCommand git clone: branch/url/path coming from user/.dependencies are now escapeshellarg'd, with a "--" separator before url/path to block option-injection (e.g. --upload-pack=evil in path). * #5 cleanDangerousTwig: twig_array_reduce (advisory call-out) plus twig_array_some/twig_array_every added to CALLABLE_DANGEROUS_NAMES. Two new test files (FileCacheSecurityTest, UnserializeIntegritySecurityTest) covering 13 cases between them; CleanDangerousTwigTest extended with the new twig_array_* entries. Full unit suite: 645 tests, 2447 assertions.
1 parent d904efc commit c66dfeb

9 files changed

Lines changed: 524 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* [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.
1414
* [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.
1515
* [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.
16+
* [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.
17+
* [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.
1618

1719
# v2.0.0-beta.1
1820
## 04/16/2026

system/src/Grav/Common/Scheduler/JobQueue.php

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace Grav\Common\Scheduler;
1111

12+
use Grav\Common\Security;
1213
use RocketTheme\Toolbox\File\JsonFile;
1314
use RuntimeException;
1415

@@ -91,8 +92,13 @@ public function push(Job $job, string $priority = self::PRIORITY_NORMAL): string
9192
'metadata' => [],
9293
];
9394

94-
// Always serialize the job to preserve its full state
95-
$queueItem['serialized_job'] = base64_encode(serialize($job));
95+
// Always serialize the job to preserve its full state. The blob is HMAC-signed
96+
// with the per-site nonce key so that a tampered queue file cannot be used to
97+
// smuggle a forged Job (which would gain RCE via Job::exec → call_user_func_array).
98+
// GHSA-vj3m-2g9h-vm4p (#1).
99+
$serialized = serialize($job);
100+
$queueItem['serialized_job'] = base64_encode($serialized);
101+
$queueItem['serialized_job_hmac'] = hash_hmac('sha256', $serialized, Security::getNonceKey());
96102

97103
$this->writeQueueItem($queueItem, 'pending');
98104

@@ -459,26 +465,37 @@ protected function getItemsInDirectory(string $directory): array
459465
*/
460466
protected function reconstructJob(array $item): ?Job
461467
{
462-
if (isset($item['serialized_job'])) {
463-
// Unserialize the job
464-
try {
465-
$job = unserialize(base64_decode((string) $item['serialized_job']));
466-
if ($job instanceof Job) {
467-
return $job;
468+
// GHSA-vj3m-2g9h-vm4p (#1): refuse to unserialize a queue item whose
469+
// HMAC is missing or doesn't match — a tampered or attacker-planted
470+
// queue file could otherwise inject a forged Job for direct RCE.
471+
if (isset($item['serialized_job'], $item['serialized_job_hmac'])) {
472+
$serialized = base64_decode((string) $item['serialized_job'], true);
473+
if ($serialized !== false) {
474+
$expected = hash_hmac('sha256', $serialized, Security::getNonceKey());
475+
if (hash_equals((string) $item['serialized_job_hmac'], $expected)) {
476+
try {
477+
$job = unserialize($serialized, ['allowed_classes' => true]);
478+
if ($job instanceof Job) {
479+
return $job;
480+
}
481+
} catch (\Exception) {
482+
return null;
483+
}
468484
}
469-
} catch (\Exception) {
470-
// Failed to unserialize
471-
return null;
472485
}
486+
// HMAC missing/mismatched/decode failed — fall through to the
487+
// structured-fields rebuild below so legitimate queue items
488+
// written before this fix still run, but without trusting their
489+
// serialized state.
473490
}
474-
491+
475492
// Create a new job from command
476493
if (isset($item['command'])) {
477494
$args = $item['arguments'] ?? [];
478495
$job = new Job($item['command'], $args, $item['job_id']);
479496
return $job;
480497
}
481-
498+
482499
return null;
483500
}
484501

system/src/Grav/Common/Security.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,12 @@ public static function getXssDefaults(): array
283283
* Property access (e.g. {{ page.header }}) is allowed; calls (header(...), obj.header(...), |header) are blocked.
284284
*/
285285
private const CALLABLE_DANGEROUS_NAMES = [
286-
// Twig internals
287-
'twig_array_map', 'twig_array_filter', 'call_user_func', 'call_user_func_array',
286+
// Twig internals — every callback-taking helper. GHSA-vj3m-2g9h-vm4p (#5)
287+
// called out the missing `twig_array_reduce`; adding the other Twig 3
288+
// callback predicates (some/every) at the same time as defense-in-depth.
289+
'twig_array_map', 'twig_array_filter', 'twig_array_reduce',
290+
'twig_array_some', 'twig_array_every',
291+
'call_user_func', 'call_user_func_array',
288292
'forward_static_call', 'forward_static_call_array',
289293
// Twig environment manipulation
290294
'registerUndefinedFunctionCallback', 'registerUndefinedFilterCallback',

system/src/Grav/Common/Session.php

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ public function started()
9797
*/
9898
public function setFlashObject($name, mixed $object)
9999
{
100-
$this->__set($name, serialize($object));
100+
// GHSA-vj3m-2g9h-vm4p (#3): wrap the serialized payload with an HMAC so a
101+
// tampered session file can't smuggle in arbitrary class instantiation.
102+
$serialized = serialize($object);
103+
$hmac = hash_hmac('sha256', $serialized, Security::getNonceKey());
104+
$this->__set($name, "v2|{$hmac}|" . $serialized);
101105

102106
return $this;
103107
}
@@ -110,9 +114,28 @@ public function setFlashObject($name, mixed $object)
110114
*/
111115
public function getFlashObject($name)
112116
{
113-
$serialized = $this->__get($name);
114-
115-
$object = is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized;
117+
$stored = $this->__get($name);
118+
119+
$object = null;
120+
if (is_string($stored) && str_starts_with($stored, 'v2|')) {
121+
// 3-field format: v2|<hmac>|<serialized>. The serialized payload may
122+
// itself contain `|`, so split with limit=3.
123+
$parts = explode('|', $stored, 3);
124+
if (count($parts) === 3) {
125+
[, $expectedHmac, $serialized] = $parts;
126+
$actualHmac = hash_hmac('sha256', $serialized, Security::getNonceKey());
127+
if (hash_equals($expectedHmac, $actualHmac)) {
128+
try {
129+
$object = unserialize($serialized, ['allowed_classes' => true]);
130+
} catch (\Throwable) {
131+
$object = null;
132+
}
133+
}
134+
}
135+
} elseif (!is_string($stored)) {
136+
$object = $stored;
137+
}
138+
// Legacy unsigned strings or HMAC mismatches fall through with $object = null.
116139

117140
$this->__unset($name);
118141

system/src/Grav/Console/Cli/InstallCommand.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,19 @@ private function gitclone(): int
147147
foreach ($this->config['git'] as $repo => $data) {
148148
$path = $this->destination . DS . $data['path'];
149149
if (!file_exists($path)) {
150-
exec('cd ' . escapeshellarg($this->destination) . ' && git clone -b ' . $data['branch'] . ' --depth 1 ' . $data['url'] . ' ' . $data['path'], $output, $return);
150+
// GHSA-vj3m-2g9h-vm4p (#4): branch/url/path come from user/.dependencies
151+
// and must be shell-escaped before reaching exec() — otherwise a planted
152+
// .dependencies file gains command injection when an admin runs install.
153+
// The bare `--` blocks option-injection in url/path positions
154+
// (e.g. a `path` value like `--upload-pack=evil`).
155+
$cmd = sprintf(
156+
'cd %s && git clone -b %s --depth 1 -- %s %s',
157+
escapeshellarg($this->destination),
158+
escapeshellarg((string) $data['branch']),
159+
escapeshellarg((string) $data['url']),
160+
escapeshellarg((string) $data['path'])
161+
);
162+
exec($cmd, $output, $return);
151163

152164
if (!$return) {
153165
$io->writeln('<green>SUCCESS</green> cloned <magenta>' . $data['url'] . '</magenta> -> <cyan>' . $path . '</cyan>');

system/src/Grav/Framework/Cache/Adapter/FileCache.php

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
use ErrorException;
1313
use FilesystemIterator;
14+
use Grav\Common\Security;
1415
use Grav\Framework\Cache\AbstractCache;
1516
use Grav\Framework\Cache\Exception\CacheException;
1617
use Grav\Framework\Cache\Exception\InvalidArgumentException;
@@ -24,10 +25,20 @@
2425
*
2526
* Defaults to 1 year TTL. Does not support unlimited TTL.
2627
*
28+
* Cache files are HMAC-signed (sha256, key from Security::getNonceKey()) so that a
29+
* tampered or attacker-planted file is rejected as a cache miss instead of
30+
* being unserialized — closes GHSA-gwfr-jfjf-92vv. The on-disk format is:
31+
*
32+
* v2\n<expires>\n<key>\n<hmac-hex>\n<serialized>
33+
*
34+
* Pre-v2 files (no version line) are treated as cache misses and rebuilt.
35+
*
2736
* @package Grav\Framework\Cache
2837
*/
2938
class FileCache extends AbstractCache
3039
{
40+
private const FORMAT_VERSION = 'v2';
41+
3142
/** @var string */
3243
private $directory;
3344
/** @var string|null */
@@ -63,20 +74,38 @@ public function doGet($key, $miss)
6374
return $miss;
6475
}
6576

66-
if ($now >= (int) $expiresAt = fgets($h)) {
77+
$version = rtrim((string)fgets($h));
78+
if ($version !== self::FORMAT_VERSION) {
79+
// Pre-v2 file (or junk). Drop it; the caller will repopulate.
6780
fclose($h);
6881
@unlink($file);
69-
} else {
70-
$i = rawurldecode(rtrim((string)fgets($h)));
71-
$value = stream_get_contents($h) ?: '';
82+
return $miss;
83+
}
84+
85+
if ($now >= (int) $expiresAt = fgets($h)) {
7286
fclose($h);
87+
@unlink($file);
88+
return $miss;
89+
}
7390

74-
if ($i === $key) {
75-
return unserialize($value, ['allowed_classes' => true]);
76-
}
91+
$i = rawurldecode(rtrim((string)fgets($h)));
92+
$expectedHmac = rtrim((string)fgets($h));
93+
$value = stream_get_contents($h) ?: '';
94+
fclose($h);
95+
96+
if ($i !== $key) {
97+
return $miss;
7798
}
7899

79-
return $miss;
100+
$actualHmac = hash_hmac('sha256', $value, Security::getNonceKey());
101+
if (!hash_equals($expectedHmac, $actualHmac)) {
102+
// Tampered or stale-secret payload — refuse to unserialize and
103+
// delete the file so it gets rebuilt cleanly next time.
104+
@unlink($file);
105+
return $miss;
106+
}
107+
108+
return unserialize($value, ['allowed_classes' => true]);
80109
}
81110

82111
/**
@@ -86,12 +115,16 @@ public function doGet($key, $miss)
86115
public function doSet($key, $value, $ttl)
87116
{
88117
$expiresAt = time() + (int)$ttl;
118+
$serialized = serialize($value);
119+
$hmac = hash_hmac('sha256', $serialized, Security::getNonceKey());
120+
121+
$payload = self::FORMAT_VERSION . "\n"
122+
. $expiresAt . "\n"
123+
. rawurlencode($key) . "\n"
124+
. $hmac . "\n"
125+
. $serialized;
89126

90-
$result = $this->write(
91-
$this->getFile($key, true),
92-
$expiresAt . "\n" . rawurlencode($key) . "\n" . serialize($value),
93-
$expiresAt
94-
);
127+
$result = $this->write($this->getFile($key, true), $payload, $expiresAt);
95128

96129
if (!$result && !is_writable($this->directory)) {
97130
throw new CacheException(sprintf('Cache directory is not writable (%s)', $this->directory));

tests/unit/Grav/Common/Security/CleanDangerousTwigTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,11 @@ public static function providerCallbackFunctions(): array
366366
['{{ array_map("system", ["id"]) }}', 'array_map with callback'],
367367
['{{ array_filter(arr, "system") }}', 'array_filter with callback'],
368368
['{{ usort(arr, "system") }}', 'usort with callback'],
369+
// GHSA-vj3m-2g9h-vm4p (#5): twig_array_reduce was missing from the
370+
// blocklist alongside its already-listed twig_array_map/filter siblings.
371+
['{{ twig_array_reduce(arr, "system", "") }}', 'twig_array_reduce GHSA-vj3m'],
372+
['{{ twig_array_some(arr, "system") }}', 'twig_array_some'],
373+
['{{ twig_array_every(arr, "system") }}', 'twig_array_every'],
369374
];
370375
}
371376

0 commit comments

Comments
 (0)