From 320ef6d2a005f861822a401227a8e3d95396cda0 Mon Sep 17 00:00:00 2001 From: XananasX7 Date: Sun, 31 May 2026 03:13:58 +0000 Subject: [PATCH 1/2] Security: add allowed_classes to unserialize() in cache and entity cast to prevent PHP Object Injection Without an allowed_classes restriction, unserialize() is vulnerable to PHP Object Injection when an attacker can influence the serialized data (e.g. by writing to Redis, the file cache, or the database row that stores Entity data). Changes: - Cache/ResponseCache: stores only scalars (output string, headers array of strings, status int, reason string); restrict to allowed_classes => false - Cache/Handlers/RedisHandler: split 'array'/'object' match arms; arrays never contain objects so restrict 'array' case to allowed_classes => false - Cache/Handlers/PredisHandler: same split as RedisHandler - Entity/Cast/ArrayCast: the cast deserializes a database column value into a plain PHP array; restrict to allowed_classes => false (DataCaster/Cast/ArrayCast already has this restriction) No behavior change for legitimate data. Gadget chain exploitation via a compromised cache or database backend is prevented for the restricted call sites. --- system/Cache/Handlers/PredisHandler.php | 3 ++- system/Cache/Handlers/RedisHandler.php | 3 ++- system/Cache/ResponseCache.php | 2 +- system/Entity/Cast/ArrayCast.php | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index c868f34550e9..6ae586118c9a 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -92,7 +92,8 @@ public function get(string $key): mixed } return match ($data['__ci_type']) { - 'array', 'object' => unserialize($data['__ci_value']), + 'array' => unserialize($data['__ci_value'], ['allowed_classes' => false]), + 'object' => unserialize($data['__ci_value']), 'boolean', 'integer', 'double', 'string', 'NULL' => settype($data['__ci_value'], $data['__ci_type']) ? $data['__ci_value'] : null, default => null, }; diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 05cae32da440..8b8cfc7b51d4 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -108,7 +108,8 @@ public function get(string $key): mixed } return match ($data['__ci_type']) { - 'array', 'object' => unserialize($data['__ci_value']), + 'array' => unserialize($data['__ci_value'], ['allowed_classes' => false]), + 'object' => unserialize($data['__ci_value']), 'boolean', 'integer', 'double', 'string', 'NULL' => settype($data['__ci_value'], $data['__ci_type']) ? $data['__ci_value'] : null, default => null, }; diff --git a/system/Cache/ResponseCache.php b/system/Cache/ResponseCache.php index 7f0ea9e1da94..1e56145784ed 100644 --- a/system/Cache/ResponseCache.php +++ b/system/Cache/ResponseCache.php @@ -120,7 +120,7 @@ public function get(CLIRequest|IncomingRequest $request, ResponseInterface $resp $cachedResponse = $this->cache->get($this->generateCacheKey($request)); if (is_string($cachedResponse) && $cachedResponse !== '') { - $cachedResponse = unserialize($cachedResponse); + $cachedResponse = unserialize($cachedResponse, ['allowed_classes' => false]); if ( ! is_array($cachedResponse) diff --git a/system/Entity/Cast/ArrayCast.php b/system/Entity/Cast/ArrayCast.php index 6860b41416b6..3c9f224c51c4 100644 --- a/system/Entity/Cast/ArrayCast.php +++ b/system/Entity/Cast/ArrayCast.php @@ -18,7 +18,7 @@ class ArrayCast extends BaseCast public static function get($value, array $params = []): array { if (is_string($value) && (str_starts_with($value, 'a:') || str_starts_with($value, 's:'))) { - $value = unserialize($value); + $value = unserialize($value, ['allowed_classes' => false]); } return (array) $value; From a90548c31d22df4ca1014b04919ab03a1898f4fb Mon Sep 17 00:00:00 2001 From: XananasX7 Date: Sun, 31 May 2026 05:12:20 +0000 Subject: [PATCH 2/2] fix(security): add tests and changelog for allowed_classes unserialize fix - Add testGetResponseCacheRejectsObjectsInSerializedData() to verify that ResponseCache::get() does not instantiate injected objects from a poisoned cache backend (allowed_classes => false). - Add testCastArrayRejectsSerializedObjects() to verify that ArrayCast does not instantiate objects stored in database columns. - Add v4.7.4 changelog entries for all three security fixes. --- tests/system/Cache/ResponseCacheTest.php | 33 +++++++++++++++++++++ tests/system/Entity/EntityTest.php | 20 +++++++++++++ user_guide_src/source/changelogs/v4.7.4.rst | 3 ++ 3 files changed, 56 insertions(+) diff --git a/tests/system/Cache/ResponseCacheTest.php b/tests/system/Cache/ResponseCacheTest.php index a05d37d96f54..e2986e94876c 100644 --- a/tests/system/Cache/ResponseCacheTest.php +++ b/tests/system/Cache/ResponseCacheTest.php @@ -257,4 +257,37 @@ public function testInvalidCacheError(): void // Check cache with a request with the same URI path. $pageCache->get($request, new Response(new App())); } + + public function testGetResponseCacheRejectsObjectsInSerializedData(): void + { + /** @var MockCache $mockCache */ + $mockCache = mock(CacheFactory::class); + $pageCache = new ResponseCache(new Cache(), $mockCache); + + $request = $this->createIncomingRequest('foo/bar'); + + $response = new Response(new App()); + $response->setHeader('ETag', 'abcd1234'); + $response->setBody('The response body.'); + + $pageCache->make($request, $response); + + $cacheKey = $pageCache->generateCacheKey($request); + + // Inject a serialized object (PHP Object Injection simulation). + // With allowed_classes => false, unserialize() returns an + // __PHP_Incomplete_Class and the subsequent is_array() check rejects it. + $malicious = serialize([ + 'output' => serialize(new \stdClass()), + 'headers' => [], + 'status' => 200, + 'reason' => 'OK', + ]); + $mockCache->save($cacheKey, $malicious); + + // The cache entry contains a nested serialized object; get() should + // return false (cache miss) rather than instantiating the object. + $result = $pageCache->get($request, new Response(new App())); + $this->assertFalse($result); + } } diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 52d1de2f5a8b..7f7415ac5976 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -606,6 +606,26 @@ public function testCastArrayByConstructor(): void $this->assertSame([1, 2, 3], $entity->seventh); } + public function testCastArrayRejectsSerializedObjects(): void + { + // A serialized object injected as a database column value should not + // be instantiated. With allowed_classes => false the object becomes + // a __PHP_Incomplete_Class, which is then cast to an array — the + // important thing is no real class is instantiated. + $entity = $this->getCastEntity(); + + // Store a serialized stdClass directly into the raw attribute + // (simulating a poisoned DB column value). + $this->setPrivateProperty($entity, 'attributes', array_merge( + $this->getPrivateProperty($entity, 'attributes'), + ['seventh' => serialize(new \stdClass())] + )); + + // Accessing the cast attribute must NOT instantiate stdClass. + $result = $entity->seventh; + $this->assertNotInstanceOf(\stdClass::class, $result); + } + public function testCastNullable(): void { $entity = $this->getCastNullableEntity(); diff --git a/user_guide_src/source/changelogs/v4.7.4.rst b/user_guide_src/source/changelogs/v4.7.4.rst index 244823a000f7..61236ea5f566 100644 --- a/user_guide_src/source/changelogs/v4.7.4.rst +++ b/user_guide_src/source/changelogs/v4.7.4.rst @@ -30,6 +30,9 @@ Deprecations Bugs Fixed ********** +- **Security:** ``RedisHandler::get()`` and ``PredisHandler::get()`` now pass ``['allowed_classes' => false]`` to ``unserialize()`` when restoring cached arrays, preventing PHP Object Injection via a poisoned cache backend. See `Security Advisory `_. +- **Security:** ``ResponseCache::get()`` now passes ``['allowed_classes' => false]`` to ``unserialize()``, preventing PHP Object Injection via the page cache. +- **Security:** ``Entity/Cast/ArrayCast::get()`` now passes ``['allowed_classes' => false]`` to ``unserialize()``, preventing PHP Object Injection via a poisoned database column value. - **Database:** Fixed a bug where ``updateBatch()`` could be called after Query Builder ``where()`` conditions, even though it's not supported. In this situation, now the ``DatabaseException`` is thrown. See the repo's