Skip to content
Draft
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) {
// Content-Type is set but is NOT text/html
if (str_starts_with(strtolower($header), strtolower('Content-Type:')) && ! str_contains(strtolower($header), strtolower('text/html'))) {
return true;
}
// File is being downloaded (Attachment)
if (str_starts_with(strtolower($header), strtolower('Content-Disposition:')) && str_contains(strtolower($header), strtolower('attachment'))) {
return true;
}
}

return false;
}

/**
* Determine if the toolbar should be disabled based on the request headers.
*
Expand Down
70 changes: 70 additions & 0 deletions tests/_support/Debug/MockNativeHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?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;

/**
* Class MockNativeHeaders
*
* This class serves as a container to hold the state of HTTP headers
* during unit testing. It allows the framework to simulate sending headers
* without actually outputting them to the CLI or browser.
*/
class MockNativeHeaders
{
/**
* Simulates the state of whether headers have been sent.
*/
public static bool $headersSent = false;

/**
* Stores the list of headers that have been sent.
*/
public static array $headers = [];

/**
* Resets the class state to defaults.
* Useful for cleaning up between individual tests.
*/
public static function reset(): void
{
self::$headersSent = false;
self::$headers = [];
}
}

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

/**
* Mock implementation of the native PHP headers_list() function.
*
* Retrieves the array of headers stored in the MockNativeHeaders class
* rather than the actual headers sent by the server.
*
* @return array The list of simulated headers.
*/
function headers_list(): array
{
return MockNativeHeaders::$headers;
}
77 changes: 77 additions & 0 deletions tests/system/Debug/ToolbarTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
use PHPUnit\Framework\Attributes\BackupGlobals;
use PHPUnit\Framework\Attributes\Group;

require_once SUPPORTPATH . 'Debug/MockNativeHeaders.php';

/**
* @internal
*/
Expand All @@ -37,6 +39,9 @@ final class ToolbarTest extends CIUnitTestCase
protected function setUp(): void
{
parent::setUp();

MockNativeHeaders::reset();

Services::reset();

is_cli(false);
Expand Down Expand Up @@ -99,4 +104,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)
MockNativeHeaders::$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
MockNativeHeaders::$headers = ['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)
MockNativeHeaders::$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
MockNativeHeaders::$headers = ['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());
}
}
Loading