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
30 changes: 30 additions & 0 deletions system/Debug/Toolbar.php
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,10 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r
* @var IncomingRequest|null $request
*/
if (CI_DEBUG && ! is_cli()) {
if ($this->hasNativeHeaderConflict()) {
return;
}

$app = service('codeigniter');

$request ??= service('request');
Expand Down Expand Up @@ -544,6 +548,32 @@ protected function format(string $data, string $format = 'html'): string
return $output;
}

/**
* Checks if the native PHP headers indicate a non-HTML response
* or if headers are already sent.
*/
protected function hasNativeHeaderConflict(): bool
{
// If headers are sent, we can't inject HTML.
if (headers_sent()) {
return true;
}

// Native Header Inspection
foreach (headers_list() as $header) {
$lowerHeader = strtolower($header);

$isNonHtmlContent = str_starts_with($lowerHeader, 'content-type:') && ! str_contains($lowerHeader, 'text/html');
$isAttachment = str_starts_with($lowerHeader, 'content-disposition:') && str_contains($lowerHeader, 'attachment');

if ($isNonHtmlContent || $isAttachment) {
return true;
}
}

return false;
}

/**
* Determine if the toolbar should be disabled based on the request headers.
*
Expand Down
64 changes: 64 additions & 0 deletions system/Test/Utilities/NativeHeadersStack.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Test\Utilities;

/**
* A utility class for simulating native PHP header handling in unit tests.
*
* @internal This class is for testing purposes only.
*/
final class NativeHeadersStack
{
/**
* Simulates whether headers have been sent.
*/
public static bool $headersSent = false;

/**
* Stores the list of headers.
*
* @var list<string>
*/
public static array $headers = [];

/**
* Resets the header stack to defaults.
* Call this in setUp() to ensure clean state between tests.
*/
public static function reset(): void
{
self::$headersSent = false;
self::$headers = [];
}

/**
* Checks if a specific header exists in the stack.
*
* @param string $header The exact header string (e.g., 'Content-Type: text/html')
*/
public static function has(string $header): bool
{
return in_array($header, self::$headers, true);
}

/**
* Adds a header to the stack.
*
* @param string $header The header to add (e.g., 'Content-Type: text/html')
*/
public static function push(string $header): void
{
self::$headers[] = $header;
}
}
42 changes: 42 additions & 0 deletions tests/_support/Mock/MockNativeHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Debug;

use CodeIgniter\Test\Utilities\NativeHeadersStack;

/**
* Mock implementation of the native PHP `headers_sent()` function.
*
* Instead of checking the actual PHP output buffer, this function
* checks the static property in NativeHeadersStack.
*
* @return bool True if headers are considered sent, false otherwise.
*/
function headers_sent(): bool
{
return NativeHeadersStack::$headersSent;
}

/**
* Mock implementation of the native PHP `headers_list()` function.
*
* Retrieves the array of headers stored in the NativeHeadersStack class
* rather than the actual headers sent by the server.
*
* @return array The list of simulated headers.
*/
function headers_list(): array
{
return NativeHeadersStack::$headers;
}
84 changes: 84 additions & 0 deletions tests/system/Debug/ToolbarTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Utilities\NativeHeadersStack;
use Config\Toolbar as ToolbarConfig;
use PHPUnit\Framework\Attributes\BackupGlobals;
use PHPUnit\Framework\Attributes\Group;
Expand All @@ -34,9 +35,20 @@ final class ToolbarTest extends CIUnitTestCase
private ?IncomingRequest $request = null;
private ?ResponseInterface $response = null;

public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();

// Load the mock once for the whole test class
require_once SUPPORTPATH . 'Mock/MockNativeHeaders.php';
}

protected function setUp(): void
{
parent::setUp();

NativeHeadersStack::reset();

Services::reset();

is_cli(false);
Expand Down Expand Up @@ -99,4 +111,76 @@ public function testPrepareInjectsNormallyWithoutIgnoredHeader(): void
// Assertions
$this->assertStringContainsString('id="debugbar_loader"', (string) $this->response->getBody());
}

// -------------------------------------------------------------------------
// Native Header Conflicts
// -------------------------------------------------------------------------

public function testPrepareAbortsIfHeadersAlreadySent(): void
{
// Headers explicitly sent (e.g., echo before execution)
NativeHeadersStack::$headersSent = true;

$this->request = service('incomingrequest', null, false);
$this->response = service('response', null, false);
$this->response->setBody('<html><body>Content</body></html>');

$toolbar = new Toolbar($this->config);
$toolbar->prepare($this->request, $this->response);

// Must NOT inject because we can't modify the body safely
$this->assertStringNotContainsString('id="debugbar_loader"', (string) $this->response->getBody());
}

public function testPrepareAbortsIfNativeContentTypeIsNotHtml(): void
{
// A library (like Dompdf) set a PDF header directly
NativeHeadersStack::push('Content-Type: application/pdf');

$this->request = service('incomingrequest', null, false);
$this->response = service('response', null, false);
// Even if the body looks like HTML (before rendering), the header says PDF
$this->response->setBody('<html><body>Raw PDF Data</body></html>');

$toolbar = new Toolbar($this->config);
$toolbar->prepare($this->request, $this->response);

// Must NOT inject into non-HTML content
$this->assertStringNotContainsString('id="debugbar_loader"', (string) $this->response->getBody());
}

public function testPrepareAbortsIfNativeContentDispositionIsAttachment(): void
{
// A file download (even if it is HTML)
NativeHeadersStack::$headers = [
'Content-Type: text/html',
'Content-Disposition: attachment; filename="report.html"',
];

$this->request = service('incomingrequest', null, false);
$this->response = service('response', null, false);
$this->response->setBody('<html><body>Downloadable Report</body></html>');

$toolbar = new Toolbar($this->config);
$toolbar->prepare($this->request, $this->response);

// Must NOT inject into downloads
$this->assertStringNotContainsString('id="debugbar_loader"', (string) $this->response->getBody());
}

public function testPrepareWorksWithNativeHtmlHeader(): void
{
// Standard scenario where PHP header is text/html
NativeHeadersStack::push('Content-Type: text/html; charset=UTF-8');

$this->request = service('incomingrequest', null, false);
$this->response = service('response', null, false);
$this->response->setBody('<html><body>Valid Page</body></html>');

$toolbar = new Toolbar($this->config);
$toolbar->prepare($this->request, $this->response);

// Should inject normally
$this->assertStringContainsString('id="debugbar_loader"', (string) $this->response->getBody());
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.7.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ Libraries
- **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()``
- **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast <time-comparing-two-times-isPast>` and :ref:`isFuture <time-comparing-two-times-isFuture>` for details.
- **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views <views-overriding-namespaced-views>` for details.
- **Toolbar:** Fixed an issue where the Debug Toolbar was incorrectly injected into responses generated by third-party libraries (e.g., Dompdf) that use native PHP headers instead of the framework's Response object.


Commands
Expand Down
Loading