Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions system/Debug/ExceptionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace CodeIgniter\Debug;

use Closure;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\CLIRequest;
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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<int, bool> $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;
}
}
}
124 changes: 124 additions & 0 deletions tests/system/Debug/ExceptionHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Config\Exceptions as ExceptionsConfig;
use Config\Services;
use PHPUnit\Framework\Attributes\Group;
use stdClass;

/**
* @internal
Expand Down Expand Up @@ -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));
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.6.4.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading