diff --git a/app/Config/Toolbar.php b/app/Config/Toolbar.php index 5a3e5045d1e2..968653e9aaac 100644 --- a/app/Config/Toolbar.php +++ b/app/Config/Toolbar.php @@ -119,4 +119,29 @@ class Toolbar extends BaseConfig public array $watchedExtensions = [ 'php', 'css', 'js', 'html', 'svg', 'json', 'env', ]; + + /** + * -------------------------------------------------------------------------- + * Ignored HTTP Headers + * -------------------------------------------------------------------------- + * + * CodeIgniter Debug Toolbar normally injects HTML and JavaScript into every + * HTML response. This is correct for full page loads, but it breaks requests + * that expect only a clean HTML fragment. + * + * Libraries like HTMX, Unpoly, and Hotwire (Turbo) update parts of the page or + * manage navigation on the client side. Injecting the Debug Toolbar into their + * responses can cause invalid HTML, duplicated scripts, or JavaScript errors + * (such as infinite loops or "Maximum call stack size exceeded"). + * + * Any request containing one of the following headers is treated as a + * client-managed or partial request, and the Debug Toolbar injection is skipped. + * + * @var array + */ + public array $disableOnHeaders = [ + 'X-Requested-With' => 'xmlhttprequest', // AJAX requests + 'HX-Request' => 'true', // HTMX requests + 'X-Up-Version' => null, // Unpoly partial requests + ]; } diff --git a/rector.php b/rector.php index ef2e55a10116..6fbb1f67d738 100644 --- a/rector.php +++ b/rector.php @@ -31,6 +31,7 @@ use Rector\EarlyReturn\Rector\If_\RemoveAlwaysElseRector; use Rector\EarlyReturn\Rector\Return_\PreparedValueToEarlyReturnRector; use Rector\Php70\Rector\FuncCall\RandomFunctionRector; +use Rector\Php71\Rector\FuncCall\RemoveExtraParametersRector; use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector; use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; use Rector\PHPUnit\CodeQuality\Rector\Class_\YieldDataProviderRector; @@ -107,6 +108,11 @@ __DIR__ . '/system/HTTP/Response.php', ], + // Exclude test file because `is_cli()` is mocked and Rector might remove needed parameters. + RemoveExtraParametersRector::class => [ + __DIR__ . '/tests/system/Debug/ToolbarTest.php', + ], + // check on constant compare UnwrapFutureCompatibleIfPhpVersionRector::class => [ __DIR__ . '/system/Autoloader/Autoloader.php', diff --git a/system/Boot.php b/system/Boot.php index 85b983c19d89..76f9fee8966d 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -144,7 +144,9 @@ public static function bootTest(Paths $paths): void static::loadDotEnv($paths); static::loadEnvironmentBootstrap($paths, false); + static::loadCommonFunctionsMock(); static::loadCommonFunctions(); + static::loadAutoloader(); static::setExceptionHandler(); static::initializeKint(); @@ -260,6 +262,11 @@ protected static function loadCommonFunctions(): void require_once SYSTEMPATH . 'Common.php'; } + protected static function loadCommonFunctionsMock(): void + { + require_once SYSTEMPATH . 'Test/Mock/MockCommon.php'; + } + /** * The autoloader allows all the pieces to work together in the framework. * We have to load it here, though, so that the config files can use the diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 7900c7c780c5..982e0db41b59 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -365,10 +365,8 @@ protected function roundTo(float $number, int $increments = 5): float /** * Prepare for debugging. - * - * @return void */ - public function prepare(?RequestInterface $request = null, ?ResponseInterface $response = null) + public function prepare(?RequestInterface $request = null, ?ResponseInterface $response = null): void { /** * @var IncomingRequest|null $request @@ -385,7 +383,7 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r return; } - $toolbar = service('toolbar', config(ToolbarConfig::class)); + $toolbar = service('toolbar', $this->config); $stats = $app->getPerformanceStats(); $data = $toolbar->run( $stats['startTime'], @@ -410,7 +408,7 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r // Non-HTML formats should not include the debugbar // then we send headers saying where to find the debug data // for this response - if ($request->isAJAX() || ! str_contains($format, 'html')) { + if ($this->shouldDisableToolbar($request) || ! str_contains($format, 'html')) { $response->setHeader('Debugbar-Time', "{$time}") ->setHeader('Debugbar-Link', site_url("?debugbar_time={$time}")); @@ -454,10 +452,8 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r * Inject debug toolbar into the response. * * @codeCoverageIgnore - * - * @return void */ - public function respond() + public function respond(): void { if (ENVIRONMENT === 'testing') { return; @@ -547,4 +543,37 @@ protected function format(string $data, string $format = 'html'): string return $output; } + + /** + * Determine if the toolbar should be disabled based on the request headers. + * + * This method allows checking both the presence of headers and their expected values. + * Useful for AJAX, HTMX, Unpoly, Turbo, etc., where partial HTML responses are expected. + * + * @return bool True if any header condition matches; false otherwise. + */ + private function shouldDisableToolbar(IncomingRequest $request): bool + { + // Fallback for older installations where the config option is missing (e.g. after upgrading from a previous version). + $headers = $this->config->disableOnHeaders ?? ['X-Requested-With' => 'xmlhttprequest']; + + foreach ($headers as $headerName => $expectedValue) { + if (! $request->hasHeader($headerName)) { + continue; // header not present, skip + } + + // If expectedValue is null, only presence is enough + if ($expectedValue === null) { + return true; + } + + $headerValue = strtolower($request->getHeaderLine($headerName)); + + if ($headerValue === strtolower($expectedValue)) { + return true; + } + } + + return false; + } } diff --git a/tests/system/Debug/ToolbarTest.php b/tests/system/Debug/ToolbarTest.php new file mode 100644 index 000000000000..16dceb943536 --- /dev/null +++ b/tests/system/Debug/ToolbarTest.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\CodeIgniter; +use CodeIgniter\Config\Factories; +use CodeIgniter\Config\Services; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Test\CIUnitTestCase; +use Config\Toolbar as ToolbarConfig; +use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[BackupGlobals(true)] +#[Group('Others')] +final class ToolbarTest extends CIUnitTestCase +{ + private ToolbarConfig $config; + private ?IncomingRequest $request = null; + private ?ResponseInterface $response = null; + + protected function setUp(): void + { + parent::setUp(); + Services::reset(); + + is_cli(false); + + $this->config = new ToolbarConfig(); + + // Mock CodeIgniter core service to provide performance stats + $app = $this->createMock(CodeIgniter::class); + $app->method('getPerformanceStats')->willReturn([ + 'startTime' => microtime(true), + 'totalTime' => 0.05, + ]); + Services::injectMock('codeigniter', $app); + } + + protected function tearDown(): void + { + // Restore is_cli state + is_cli(true); + + parent::tearDown(); + } + + public function testPrepareRespectsDisableOnHeaders(): void + { + // Set up the new configuration property + $this->config->disableOnHeaders = ['HX-Request' => 'true']; + Factories::injectMock('config', 'Toolbar', $this->config); + + // Initialize Request with the custom header + $this->request = service('incomingrequest', null, false); + $this->request->setHeader('HX-Request', 'true'); + + // Initialize Response + $this->response = service('response', null, false); + $this->response->setBody('Content'); + $this->response->setHeader('Content-Type', 'text/html'); + + $toolbar = new Toolbar($this->config); + $toolbar->prepare($this->request, $this->response); + + // Assertions + $this->assertTrue($this->response->hasHeader('Debugbar-Time')); + $this->assertStringNotContainsString('id="debugbar_loader"', (string) $this->response->getBody()); + } + + public function testPrepareInjectsNormallyWithoutIgnoredHeader(): void + { + $this->config->disableOnHeaders = ['HX-Request' => 'true']; + Factories::injectMock('config', 'Toolbar', $this->config); + + $this->request = service('incomingrequest', null, false); + $this->response = service('response', null, false); + $this->response->setBody('Content'); + $this->response->setHeader('Content-Type', 'text/html'); + + $toolbar = new Toolbar($this->config); + $toolbar->prepare($this->request, $this->response); + + // Assertions + $this->assertStringContainsString('id="debugbar_loader"', (string) $this->response->getBody()); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 70b27b15a600..3a1d274152b9 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -96,6 +96,9 @@ Method Signature Changes - ``clean()`` - ``getCacheInfo()`` - ``getMetaData()`` +- Added native return types to ``CodeIgniter\Debug\Toolbar`` methods: + - ``prepare(): void`` + - ``respond(): void`` Removed Deprecated Items ======================== @@ -178,6 +181,8 @@ Changes - **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s T`` to follow the recommended format in RFC 7231. - **Format:** Added support for configuring ``json_encode()`` maximum depth via ``Config\Format::$jsonEncodeDepth``. - **Paths:** Added support for changing the location of the ``.env`` file via the ``Paths::$envDirectory`` property. +- **Toolbar:** Added ``$disableOnHeaders`` property to **app/Config/Toolbar.php**. + ************ Deprecations @@ -193,6 +198,7 @@ Bugs Fixed - **Cookie:** The ``CookieInterface::SAMESITE_STRICT``, ``CookieInterface::SAMESITE_LAX``, and ``CookieInterface::SAMESITE_NONE`` constants are now written in ucfirst style to be consistent with usage in the rest of the framework. - **Cache:** Changed ``WincacheHandler::increment()`` and ``WincacheHandler::decrement()`` to return ``bool`` instead of ``mixed``. +- **Toolbar:** Fixed **Maximum call stack size exceeded** crash when AJAX-like requests (HTMX, Turbo, Unpoly, etc.) were made on pages with Debug Toolbar enabled. See the repo's `CHANGELOG.md `_