diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 982e0db41b59..cf7418acfc20 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -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'); @@ -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. * diff --git a/tests/_support/Debug/MockNativeHeaders.php b/tests/_support/Debug/MockNativeHeaders.php new file mode 100644 index 000000000000..2bf72ea922d4 --- /dev/null +++ b/tests/_support/Debug/MockNativeHeaders.php @@ -0,0 +1,70 @@ + + * + * 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; +} diff --git a/tests/system/Debug/ToolbarTest.php b/tests/system/Debug/ToolbarTest.php index 16dceb943536..e0387abfd9eb 100644 --- a/tests/system/Debug/ToolbarTest.php +++ b/tests/system/Debug/ToolbarTest.php @@ -23,6 +23,8 @@ use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; +require_once SUPPORTPATH . 'Debug/MockNativeHeaders.php'; + /** * @internal */ @@ -37,6 +39,9 @@ final class ToolbarTest extends CIUnitTestCase protected function setUp(): void { parent::setUp(); + + MockNativeHeaders::reset(); + Services::reset(); is_cli(false); @@ -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('Content'); + + $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('Raw PDF Data'); + + $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('Downloadable Report'); + + $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('Valid Page'); + + $toolbar = new Toolbar($this->config); + $toolbar->prepare($this->request, $this->response); + + // Should inject normally + $this->assertStringContainsString('id="debugbar_loader"', (string) $this->response->getBody()); + } }