diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php index 76d5e0f2e504..805b5691c079 100644 --- a/system/Debug/ExceptionHandler.php +++ b/system/Debug/ExceptionHandler.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Debug; +use Closure; use CodeIgniter\API\ResponseTrait; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\CLIRequest; @@ -85,6 +86,12 @@ public function handle( ? $this->collectVars($exception, $statusCode) : ''; + // Sanitize data to remove non-JSON-serializable values (resources, closures) + // before formatting for API responses (JSON, XML, etc.) + if ($data !== '') { + $data = $this->sanitizeData($data); + } + $this->respond($data, $statusCode)->send(); if (ENVIRONMENT !== 'testing') { @@ -167,4 +174,53 @@ private function isDisplayErrorsEnabled(): bool true, ); } + + /** + * Sanitizes data to remove non-JSON-serializable values like resources and closures. + * This is necessary for API responses that need to be JSON/XML encoded. + * + * @param array $seen Used internally to prevent infinite recursion + */ + private function sanitizeData(mixed $data, array &$seen = []): mixed + { + $type = gettype($data); + + switch ($type) { + case 'resource': + case 'resource (closed)': + return '[Resource #' . (int) $data . ']'; + + case 'array': + $result = []; + + foreach ($data as $key => $value) { + $result[$key] = $this->sanitizeData($value, $seen); + } + + return $result; + + case 'object': + $oid = spl_object_id($data); + if (isset($seen[$oid])) { + return '[' . $data::class . ' Object *RECURSION*]'; + } + $seen[$oid] = true; + + if ($data instanceof Closure) { + return '[Closure]'; + } + + $result = []; + + foreach ((array) $data as $key => $value) { + $cleanKey = preg_replace('/^\x00.*\x00/', '', (string) $key); + $result[$cleanKey] = $this->sanitizeData($value, $seen); + } + + return $result; + + default: + return $data; + } + } } diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index a278ade8be7e..a1958be5f36b 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -22,6 +22,7 @@ use Config\Exceptions as ExceptionsConfig; use Config\Services; use PHPUnit\Framework\Attributes\Group; +use stdClass; /** * @internal @@ -262,4 +263,127 @@ public function testHighlightFile(): void $this->restoreIniValues(); } + + public function testSanitizeDataWithResource(): void + { + $sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData'); + + // Create a resource (file handle) + $resource = fopen('php://memory', 'rb'); + $result = $sanitizeData($resource); + + $this->assertIsString($result); + $this->assertStringStartsWith('[Resource #', $result); + $this->assertStringEndsWith(']', $result); + + fclose($resource); + } + + public function testSanitizeDataWithClosure(): void + { + $sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData'); + + $closure = static fn (): string => 'test'; + $result = $sanitizeData($closure); + + $this->assertSame('[Closure]', $result); + } + + public function testSanitizeDataWithCircularReference(): void + { + $sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData'); + + // Create an object with circular reference + $obj = new stdClass(); + $obj->self = $obj; + + $result = $sanitizeData($obj); + + $this->assertIsArray($result); + $this->assertArrayHasKey('self', $result); + $this->assertStringContainsString('*RECURSION*', (string) $result['self']); + $this->assertStringContainsString('stdClass', (string) $result['self']); + } + + public function testSanitizeDataWithArrayContainingResource(): void + { + $sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData'); + + $resource = fopen('php://memory', 'rb'); + $data = [ + 'string' => 'test', + 'number' => 123, + 'resource' => $resource, + ]; + + $result = $sanitizeData($data); + + $this->assertIsArray($result); + $this->assertSame('test', $result['string']); + $this->assertSame(123, $result['number']); + $this->assertIsString($result['resource']); + $this->assertStringStartsWith('[Resource #', $result['resource']); + + fclose($resource); + } + + public function testSanitizeDataWithObjectContainingResource(): void + { + $sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData'); + + $resource = fopen('php://memory', 'rb'); + + $obj = new stdClass(); + $obj->name = 'test'; + $obj->connID = $resource; + $obj->database = 'mydb'; + + $result = $sanitizeData($obj); + + $this->assertIsArray($result); + $this->assertSame('test', $result['name']); + $this->assertSame('mydb', $result['database']); + $this->assertIsString($result['connID']); + $this->assertStringStartsWith('[Resource #', $result['connID']); + + fclose($resource); + } + + public function testSanitizeDataWithNestedObjects(): void + { + $sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData'); + + $resource = fopen('php://memory', 'rb'); + + $inner = new stdClass(); + $inner->connID = $resource; + $inner->host = 'localhost'; + + $outer = new stdClass(); + $outer->db = $inner; + $outer->cache = 'file'; + + $result = $sanitizeData($outer); + + $this->assertIsArray($result); + $this->assertSame('file', $result['cache']); + $this->assertIsArray($result['db']); + $this->assertSame('localhost', $result['db']['host']); + $this->assertIsString($result['db']['connID']); + $this->assertStringStartsWith('[Resource #', $result['db']['connID']); + + fclose($resource); + } + + public function testSanitizeDataWithScalars(): void + { + $sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData'); + + $this->assertSame('string', $sanitizeData('string')); + $this->assertSame(123, $sanitizeData(123)); + $this->assertEqualsWithDelta(45.67, $sanitizeData(45.67), PHP_FLOAT_EPSILON); + $this->assertTrue($sanitizeData(true)); + $this->assertFalse($sanitizeData(false)); + $this->assertNull($sanitizeData(null)); + } } diff --git a/user_guide_src/source/changelogs/v4.6.4.rst b/user_guide_src/source/changelogs/v4.6.4.rst index c8cc53437233..edc679e5cb7b 100644 --- a/user_guide_src/source/changelogs/v4.6.4.rst +++ b/user_guide_src/source/changelogs/v4.6.4.rst @@ -41,6 +41,7 @@ Bugs Fixed - **Database:** Fixed a bug in ``Connection::getFieldData()`` for ``SQLSRV`` and ``OCI8`` where extra characters were returned in column default values (specific to those handlers), instead of following the convention used by other drivers. - **Database:** Fixed a bug in ``BaseBuilder::compileOrderBy()`` where the method could overwrite ``QBOrderBy`` with a string instead of keeping it as an array, causing type errors and preventing additional ``ORDER BY`` clauses from being appended. - **Database:** Fixed a bug in ``SQLite3`` where the password parameter was ignored unless it was an empty string. +- **Debug:** Fixed a bug in ``ExceptionHandler`` where JSON encoding would fail when exception traces contained resources (e.g., database connections), closures, or circular references. - **Forge:** Fixed a bug in ``Postgre`` and ``SQLSRV`` where changing a column's default value using ``Forge::modifyColumn()`` method produced incorrect SQL syntax. - **Model:** Fixed a bug in ``Model::replace()`` where ``created_at`` field (when available) wasn't set correctly. - **Model:** Fixed a bug in ``Model::insertBatch()`` and ``Model::updateBatch()`` where casts were not applied to inserted or updated values.