Skip to content

Commit 9972ca0

Browse files
authored
fix: handle resources and closures in JSON exception responses (#9788)
* fix: handle resources and closures in JSON exception responses * add changelog
1 parent d01b5f3 commit 9972ca0

File tree

3 files changed

+181
-0
lines changed

3 files changed

+181
-0
lines changed

system/Debug/ExceptionHandler.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace CodeIgniter\Debug;
1515

16+
use Closure;
1617
use CodeIgniter\API\ResponseTrait;
1718
use CodeIgniter\Exceptions\PageNotFoundException;
1819
use CodeIgniter\HTTP\CLIRequest;
@@ -85,6 +86,12 @@ public function handle(
8586
? $this->collectVars($exception, $statusCode)
8687
: '';
8788

89+
// Sanitize data to remove non-JSON-serializable values (resources, closures)
90+
// before formatting for API responses (JSON, XML, etc.)
91+
if ($data !== '') {
92+
$data = $this->sanitizeData($data);
93+
}
94+
8895
$this->respond($data, $statusCode)->send();
8996

9097
if (ENVIRONMENT !== 'testing') {
@@ -167,4 +174,53 @@ private function isDisplayErrorsEnabled(): bool
167174
true,
168175
);
169176
}
177+
178+
/**
179+
* Sanitizes data to remove non-JSON-serializable values like resources and closures.
180+
* This is necessary for API responses that need to be JSON/XML encoded.
181+
*
182+
* @param array<int, bool> $seen Used internally to prevent infinite recursion
183+
*/
184+
private function sanitizeData(mixed $data, array &$seen = []): mixed
185+
{
186+
$type = gettype($data);
187+
188+
switch ($type) {
189+
case 'resource':
190+
case 'resource (closed)':
191+
return '[Resource #' . (int) $data . ']';
192+
193+
case 'array':
194+
$result = [];
195+
196+
foreach ($data as $key => $value) {
197+
$result[$key] = $this->sanitizeData($value, $seen);
198+
}
199+
200+
return $result;
201+
202+
case 'object':
203+
$oid = spl_object_id($data);
204+
if (isset($seen[$oid])) {
205+
return '[' . $data::class . ' Object *RECURSION*]';
206+
}
207+
$seen[$oid] = true;
208+
209+
if ($data instanceof Closure) {
210+
return '[Closure]';
211+
}
212+
213+
$result = [];
214+
215+
foreach ((array) $data as $key => $value) {
216+
$cleanKey = preg_replace('/^\x00.*\x00/', '', (string) $key);
217+
$result[$cleanKey] = $this->sanitizeData($value, $seen);
218+
}
219+
220+
return $result;
221+
222+
default:
223+
return $data;
224+
}
225+
}
170226
}

tests/system/Debug/ExceptionHandlerTest.php

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Config\Exceptions as ExceptionsConfig;
2323
use Config\Services;
2424
use PHPUnit\Framework\Attributes\Group;
25+
use stdClass;
2526

2627
/**
2728
* @internal
@@ -262,4 +263,127 @@ public function testHighlightFile(): void
262263

263264
$this->restoreIniValues();
264265
}
266+
267+
public function testSanitizeDataWithResource(): void
268+
{
269+
$sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData');
270+
271+
// Create a resource (file handle)
272+
$resource = fopen('php://memory', 'rb');
273+
$result = $sanitizeData($resource);
274+
275+
$this->assertIsString($result);
276+
$this->assertStringStartsWith('[Resource #', $result);
277+
$this->assertStringEndsWith(']', $result);
278+
279+
fclose($resource);
280+
}
281+
282+
public function testSanitizeDataWithClosure(): void
283+
{
284+
$sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData');
285+
286+
$closure = static fn (): string => 'test';
287+
$result = $sanitizeData($closure);
288+
289+
$this->assertSame('[Closure]', $result);
290+
}
291+
292+
public function testSanitizeDataWithCircularReference(): void
293+
{
294+
$sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData');
295+
296+
// Create an object with circular reference
297+
$obj = new stdClass();
298+
$obj->self = $obj;
299+
300+
$result = $sanitizeData($obj);
301+
302+
$this->assertIsArray($result);
303+
$this->assertArrayHasKey('self', $result);
304+
$this->assertStringContainsString('*RECURSION*', (string) $result['self']);
305+
$this->assertStringContainsString('stdClass', (string) $result['self']);
306+
}
307+
308+
public function testSanitizeDataWithArrayContainingResource(): void
309+
{
310+
$sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData');
311+
312+
$resource = fopen('php://memory', 'rb');
313+
$data = [
314+
'string' => 'test',
315+
'number' => 123,
316+
'resource' => $resource,
317+
];
318+
319+
$result = $sanitizeData($data);
320+
321+
$this->assertIsArray($result);
322+
$this->assertSame('test', $result['string']);
323+
$this->assertSame(123, $result['number']);
324+
$this->assertIsString($result['resource']);
325+
$this->assertStringStartsWith('[Resource #', $result['resource']);
326+
327+
fclose($resource);
328+
}
329+
330+
public function testSanitizeDataWithObjectContainingResource(): void
331+
{
332+
$sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData');
333+
334+
$resource = fopen('php://memory', 'rb');
335+
336+
$obj = new stdClass();
337+
$obj->name = 'test';
338+
$obj->connID = $resource;
339+
$obj->database = 'mydb';
340+
341+
$result = $sanitizeData($obj);
342+
343+
$this->assertIsArray($result);
344+
$this->assertSame('test', $result['name']);
345+
$this->assertSame('mydb', $result['database']);
346+
$this->assertIsString($result['connID']);
347+
$this->assertStringStartsWith('[Resource #', $result['connID']);
348+
349+
fclose($resource);
350+
}
351+
352+
public function testSanitizeDataWithNestedObjects(): void
353+
{
354+
$sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData');
355+
356+
$resource = fopen('php://memory', 'rb');
357+
358+
$inner = new stdClass();
359+
$inner->connID = $resource;
360+
$inner->host = 'localhost';
361+
362+
$outer = new stdClass();
363+
$outer->db = $inner;
364+
$outer->cache = 'file';
365+
366+
$result = $sanitizeData($outer);
367+
368+
$this->assertIsArray($result);
369+
$this->assertSame('file', $result['cache']);
370+
$this->assertIsArray($result['db']);
371+
$this->assertSame('localhost', $result['db']['host']);
372+
$this->assertIsString($result['db']['connID']);
373+
$this->assertStringStartsWith('[Resource #', $result['db']['connID']);
374+
375+
fclose($resource);
376+
}
377+
378+
public function testSanitizeDataWithScalars(): void
379+
{
380+
$sanitizeData = self::getPrivateMethodInvoker($this->handler, 'sanitizeData');
381+
382+
$this->assertSame('string', $sanitizeData('string'));
383+
$this->assertSame(123, $sanitizeData(123));
384+
$this->assertEqualsWithDelta(45.67, $sanitizeData(45.67), PHP_FLOAT_EPSILON);
385+
$this->assertTrue($sanitizeData(true));
386+
$this->assertFalse($sanitizeData(false));
387+
$this->assertNull($sanitizeData(null));
388+
}
265389
}

user_guide_src/source/changelogs/v4.6.4.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Bugs Fixed
4141
- **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.
4242
- **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.
4343
- **Database:** Fixed a bug in ``SQLite3`` where the password parameter was ignored unless it was an empty string.
44+
- **Debug:** Fixed a bug in ``ExceptionHandler`` where JSON encoding would fail when exception traces contained resources (e.g., database connections), closures, or circular references.
4445
- **Forge:** Fixed a bug in ``Postgre`` and ``SQLSRV`` where changing a column's default value using ``Forge::modifyColumn()`` method produced incorrect SQL syntax.
4546
- **Model:** Fixed a bug in ``Model::replace()`` where ``created_at`` field (when available) wasn't set correctly.
4647
- **Model:** Fixed a bug in ``Model::insertBatch()`` and ``Model::updateBatch()`` where casts were not applied to inserted or updated values.

0 commit comments

Comments
 (0)