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; 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