diff --git a/app/Config/Toolbar.php b/app/Config/Toolbar.php index 5a3e5045d1e2..454349bd4427 100644 --- a/app/Config/Toolbar.php +++ b/app/Config/Toolbar.php @@ -119,4 +119,28 @@ 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 list + */ + public array $disableOnHeaders = [ + 'HX-Request', // HTMX partial requests + 'X-Up-Version', // Unpoly partial requests + ]; } diff --git a/rector.php b/rector.php index ef2e55a10116..55e8c6a66a03 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,10 @@ __DIR__ . '/system/HTTP/Response.php', ], + 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..fac06e42ec70 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -42,6 +42,12 @@ class Toolbar */ protected $config; + /** + * Indicates if the current request is a custom AJAX-like request + * (HTMX, Unpoly, Turbo, etc.) that expects clean HTML fragments. + */ + protected bool $isCustomAjax = false; + /** * Collectors to be used and displayed. * @@ -365,10 +371,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 +389,9 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r return; } - $toolbar = service('toolbar', config(ToolbarConfig::class)); + $config = config(ToolbarConfig::class); + + $toolbar = service('toolbar', $config); $stats = $app->getPerformanceStats(); $data = $toolbar->run( $stats['startTime'], @@ -407,10 +413,17 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r $format = $response->getHeaderLine('content-type'); + foreach ($config->disableOnHeaders as $header) { + if ($request->hasHeader($header)) { + $this->isCustomAjax = true; + break; + } + } + // 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 ($request->isAJAX() || ! str_contains($format, 'html') || $this->isCustomAjax) { $response->setHeader('Debugbar-Time', "{$time}") ->setHeader('Debugbar-Link', site_url("?debugbar_time={$time}")); @@ -454,10 +467,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; diff --git a/tests/system/Debug/ToolbarTest.php b/tests/system/Debug/ToolbarTest.php new file mode 100644 index 000000000000..2203b5aa6865 --- /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']; + 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']; + 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..e1364e10c1e6 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -178,6 +178,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 +195,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 `_