From d0086784eb04ea0dfb86f1f07edd6f3d2d4d91a5 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Thu, 1 Jan 2026 09:46:20 +0330 Subject: [PATCH 01/11] feat: add native header detection to DebugToolbar --- system/Debug/Toolbar.php | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 982e0db41b59..aa52d9ded438 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -372,6 +372,11 @@ 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 +549,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. * From 2e5ad15ea29a9332a4894f04a88dd1d3cc86d883 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Thu, 1 Jan 2026 09:59:43 +0330 Subject: [PATCH 02/11] test: add infrastructure for mocking native header functions --- tests/_support/Debug/MockNativeHeaders.php | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/_support/Debug/MockNativeHeaders.php 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; +} From a191407b3008db3fb799431cc042adf548adaf49 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Thu, 1 Jan 2026 10:01:26 +0330 Subject: [PATCH 03/11] test: add test for native header conflict detection --- system/Debug/Toolbar.php | 1 - tests/system/Debug/ToolbarTest.php | 77 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index aa52d9ded438..cf7418acfc20 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -372,7 +372,6 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r * @var IncomingRequest|null $request */ if (CI_DEBUG && ! is_cli()) { - if ($this->hasNativeHeaderConflict()) { return; } 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()); + } } From 2c0846c4e86f7ffb3641675acc7c5d40857e28df Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Sun, 4 Jan 2026 16:03:58 +0330 Subject: [PATCH 04/11] test: introduce NativeHeadersStack utility for native header testing --- system/Test/Utilities/NativeHeadersStack.php | 147 +++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 system/Test/Utilities/NativeHeadersStack.php diff --git a/system/Test/Utilities/NativeHeadersStack.php b/system/Test/Utilities/NativeHeadersStack.php new file mode 100644 index 000000000000..adab37db7467 --- /dev/null +++ b/system/Test/Utilities/NativeHeadersStack.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Utilities; + +/** + * Class NativeHeadersStack + * + * A utility class for simulating native PHP header handling in unit tests. + * It allows the inspection, manipulation, and mocking of HTTP headers without + * affecting the actual HTTP output. + * + * @internal This class is for testing purposes only. + */ +final class NativeHeadersStack +{ + private static bool $headersSent = false; + + /** + * @var array> + */ + private static array $headers = []; + + private static ?int $responseCode = null; + + /** + * Resets the state of the class to its default values. + */ + public static function reset(): void + { + self::$headersSent = false; + self::$headers = []; + self::$responseCode = null; + } + + /** + * Sets the state of whether headers have been sent. + */ + public static function setHeadersSent(bool $sent): void + { + self::$headersSent = $sent; + } + + /** + * Simulates PHP's native `headers_sent()` function. + */ + public static function headersSent(): bool + { + return self::$headersSent; + } + + /** + * Sets a header by name, replacing or appending it. + * This is the main method for header manipulation. + * + * @param string $header The header string (e.g., 'Content-Type: application/json'). + * @param bool $replace Whether to replace a previous similar header. + * @param int|null $responseCode Forces the HTTP response code to the specified value. + */ + public static function set(string $header, bool $replace = true, ?int $responseCode = null): void + { + if (str_contains($header, ':')) { + [$name, $value] = explode(':', $header, 2); + $name = trim($name); + $value = trim($value); + + if ($replace || ! isset(self::$headers[strtolower($name)])) { + self::$headers[strtolower($name)] = []; + } + self::$headers[strtolower($name)][] = "{$name}: {$value}"; + } else { + // Handle non-key-value headers like "HTTP/1.1 404 Not Found" + self::$headers['status'][] = $header; + } + + if ($responseCode !== null) { + self::$responseCode = $responseCode; + } + } + + /** + * Pushes a header to the stack without replacing existing ones. + */ + public static function push(string $header): void + { + self::set($header, false); + } + + /** + * A convenience method to push multiple headers at once. + * + * @param list $headers An array of headers to push onto the stack. + */ + public static function pushMany(array $headers): void + { + foreach ($headers as $header) { + // Default to not replacing for multiple adds + self::set($header, false); + } + } + + /** + * Simulates PHP's `headers_list()` function. + * + * @return list The list of simulated headers. + */ + public static function listHeaders(): array + { + $list = []; + + foreach (self::$headers as $values) { + $list = array_merge($list, $values); + } + + return $list; + } + + /** + * Checks if a header with the given name exists in the stack (case-insensitive). + * + * @param string $name The header name to search for (e.g., 'Content-Type'). + */ + public static function hasHeader(string $name): bool + { + return isset(self::$headers[strtolower($name)]); + } + + /** + * Simulates PHP's `http_response_code()` function. + * + * @return int|null The stored response code, or null if not set. + */ + public static function getResponseCode(): ?int + { + return self::$responseCode; + } +} From d8558d2c99d205ab449c44396a7384ba838fca47 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Sun, 4 Jan 2026 16:08:26 +0330 Subject: [PATCH 05/11] test: centralize native header function mocks --- tests/_support/Mock/MockNativeHeaders.php | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/_support/Mock/MockNativeHeaders.php diff --git a/tests/_support/Mock/MockNativeHeaders.php b/tests/_support/Mock/MockNativeHeaders.php new file mode 100644 index 000000000000..9873ae2df7a3 --- /dev/null +++ b/tests/_support/Mock/MockNativeHeaders.php @@ -0,0 +1,42 @@ + + * + * 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::listHeaders(); +} From 09cad29ac410869ae013b10d9b1f4271d563d86e Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Sun, 4 Jan 2026 16:10:26 +0330 Subject: [PATCH 06/11] test: refactor existing tests to use NativeHeadersStack mocks --- tests/_support/Debug/MockNativeHeaders.php | 70 ---------------------- tests/system/Debug/ToolbarTest.php | 15 ++--- 2 files changed, 8 insertions(+), 77 deletions(-) delete mode 100644 tests/_support/Debug/MockNativeHeaders.php diff --git a/tests/_support/Debug/MockNativeHeaders.php b/tests/_support/Debug/MockNativeHeaders.php deleted file mode 100644 index 2bf72ea922d4..000000000000 --- a/tests/_support/Debug/MockNativeHeaders.php +++ /dev/null @@ -1,70 +0,0 @@ - - * - * 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 e0387abfd9eb..0ce6903467aa 100644 --- a/tests/system/Debug/ToolbarTest.php +++ b/tests/system/Debug/ToolbarTest.php @@ -19,11 +19,12 @@ 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; -require_once SUPPORTPATH . 'Debug/MockNativeHeaders.php'; +require_once SUPPORTPATH . 'Mock/MockNativeHeaders.php'; /** * @internal @@ -40,7 +41,7 @@ protected function setUp(): void { parent::setUp(); - MockNativeHeaders::reset(); + NativeHeadersStack::reset(); Services::reset(); @@ -112,7 +113,7 @@ public function testPrepareInjectsNormallyWithoutIgnoredHeader(): void public function testPrepareAbortsIfHeadersAlreadySent(): void { // Headers explicitly sent (e.g., echo before execution) - MockNativeHeaders::$headersSent = true; + NativeHeadersStack::setHeadersSent(true); $this->request = service('incomingrequest', null, false); $this->response = service('response', null, false); @@ -128,7 +129,7 @@ public function testPrepareAbortsIfHeadersAlreadySent(): void public function testPrepareAbortsIfNativeContentTypeIsNotHtml(): void { // A library (like Dompdf) set a PDF header directly - MockNativeHeaders::$headers = ['Content-Type: application/pdf']; + NativeHeadersStack::set('Content-Type: application/pdf'); $this->request = service('incomingrequest', null, false); $this->response = service('response', null, false); @@ -145,10 +146,10 @@ public function testPrepareAbortsIfNativeContentTypeIsNotHtml(): void public function testPrepareAbortsIfNativeContentDispositionIsAttachment(): void { // A file download (even if it is HTML) - MockNativeHeaders::$headers = [ + NativeHeadersStack::pushMany([ 'Content-Type: text/html', 'Content-Disposition: attachment; filename="report.html"', - ]; + ]); $this->request = service('incomingrequest', null, false); $this->response = service('response', null, false); @@ -164,7 +165,7 @@ public function testPrepareAbortsIfNativeContentDispositionIsAttachment(): void public function testPrepareWorksWithNativeHtmlHeader(): void { // Standard scenario where PHP header is text/html - MockNativeHeaders::$headers = ['Content-Type: text/html; charset=UTF-8']; + NativeHeadersStack::set('Content-Type: text/html; charset=UTF-8'); $this->request = service('incomingrequest', null, false); $this->response = service('response', null, false); From 8acf0623e03e18f1543bdd41a8040d5b699b6fd9 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Sun, 4 Jan 2026 18:28:36 +0330 Subject: [PATCH 07/11] test: refactor NativeHeadersStack to use simplified header simulation --- system/Test/Utilities/NativeHeadersStack.php | 119 +++---------------- tests/_support/Mock/MockNativeHeaders.php | 4 +- tests/system/Debug/ToolbarTest.php | 10 +- 3 files changed, 25 insertions(+), 108 deletions(-) diff --git a/system/Test/Utilities/NativeHeadersStack.php b/system/Test/Utilities/NativeHeadersStack.php index adab37db7467..be88b235836c 100644 --- a/system/Test/Utilities/NativeHeadersStack.php +++ b/system/Test/Utilities/NativeHeadersStack.php @@ -14,134 +14,51 @@ namespace CodeIgniter\Test\Utilities; /** - * Class NativeHeadersStack - * * A utility class for simulating native PHP header handling in unit tests. - * It allows the inspection, manipulation, and mocking of HTTP headers without - * affecting the actual HTTP output. * * @internal This class is for testing purposes only. */ final class NativeHeadersStack { - private static bool $headersSent = false; - - /** - * @var array> - */ - private static array $headers = []; - - private static ?int $responseCode = null; - - /** - * Resets the state of the class to its default values. - */ - public static function reset(): void - { - self::$headersSent = false; - self::$headers = []; - self::$responseCode = null; - } - - /** - * Sets the state of whether headers have been sent. - */ - public static function setHeadersSent(bool $sent): void - { - self::$headersSent = $sent; - } - - /** - * Simulates PHP's native `headers_sent()` function. - */ - public static function headersSent(): bool - { - return self::$headersSent; - } - - /** - * Sets a header by name, replacing or appending it. - * This is the main method for header manipulation. - * - * @param string $header The header string (e.g., 'Content-Type: application/json'). - * @param bool $replace Whether to replace a previous similar header. - * @param int|null $responseCode Forces the HTTP response code to the specified value. - */ - public static function set(string $header, bool $replace = true, ?int $responseCode = null): void - { - if (str_contains($header, ':')) { - [$name, $value] = explode(':', $header, 2); - $name = trim($name); - $value = trim($value); - - if ($replace || ! isset(self::$headers[strtolower($name)])) { - self::$headers[strtolower($name)] = []; - } - self::$headers[strtolower($name)][] = "{$name}: {$value}"; - } else { - // Handle non-key-value headers like "HTTP/1.1 404 Not Found" - self::$headers['status'][] = $header; - } - - if ($responseCode !== null) { - self::$responseCode = $responseCode; - } - } - /** - * Pushes a header to the stack without replacing existing ones. + * Simulates whether headers have been sent. */ - public static function push(string $header): void - { - self::set($header, false); - } + public static bool $headersSent = false; /** - * A convenience method to push multiple headers at once. + * Stores the list of headers. * - * @param list $headers An array of headers to push onto the stack. + * @var list */ - public static function pushMany(array $headers): void - { - foreach ($headers as $header) { - // Default to not replacing for multiple adds - self::set($header, false); - } - } + public static array $headers = []; /** - * Simulates PHP's `headers_list()` function. - * - * @return list The list of simulated headers. + * Resets the header stack to defaults. + * Call this in setUp() to ensure clean state between tests. */ - public static function listHeaders(): array + public static function reset(): void { - $list = []; - - foreach (self::$headers as $values) { - $list = array_merge($list, $values); - } - - return $list; + self::$headersSent = false; + self::$headers = []; } /** - * Checks if a header with the given name exists in the stack (case-insensitive). + * Checks if a specific header exists in the stack. * - * @param string $name The header name to search for (e.g., 'Content-Type'). + * @param string $header The exact header string (e.g., 'Content-Type: text/html') */ - public static function hasHeader(string $name): bool + public static function has(string $header): bool { - return isset(self::$headers[strtolower($name)]); + return in_array($header, self::$headers, true); } /** - * Simulates PHP's `http_response_code()` function. + * Adds a header to the stack. * - * @return int|null The stored response code, or null if not set. + * @param string $header The header to add (e.g., 'Content-Type: text/html') */ - public static function getResponseCode(): ?int + public static function push(string $header): void { - return self::$responseCode; + self::$headers[] = $header; } } diff --git a/tests/_support/Mock/MockNativeHeaders.php b/tests/_support/Mock/MockNativeHeaders.php index 9873ae2df7a3..f901528661d4 100644 --- a/tests/_support/Mock/MockNativeHeaders.php +++ b/tests/_support/Mock/MockNativeHeaders.php @@ -25,7 +25,7 @@ */ function headers_sent(): bool { - return NativeHeadersStack::headersSent(); + return NativeHeadersStack::$headersSent; } /** @@ -38,5 +38,5 @@ function headers_sent(): bool */ function headers_list(): array { - return NativeHeadersStack::listHeaders(); + return NativeHeadersStack::$headers; } diff --git a/tests/system/Debug/ToolbarTest.php b/tests/system/Debug/ToolbarTest.php index 0ce6903467aa..b7b388751994 100644 --- a/tests/system/Debug/ToolbarTest.php +++ b/tests/system/Debug/ToolbarTest.php @@ -113,7 +113,7 @@ public function testPrepareInjectsNormallyWithoutIgnoredHeader(): void public function testPrepareAbortsIfHeadersAlreadySent(): void { // Headers explicitly sent (e.g., echo before execution) - NativeHeadersStack::setHeadersSent(true); + NativeHeadersStack::$headersSent = true; $this->request = service('incomingrequest', null, false); $this->response = service('response', null, false); @@ -129,7 +129,7 @@ public function testPrepareAbortsIfHeadersAlreadySent(): void public function testPrepareAbortsIfNativeContentTypeIsNotHtml(): void { // A library (like Dompdf) set a PDF header directly - NativeHeadersStack::set('Content-Type: application/pdf'); + NativeHeadersStack::push('Content-Type: application/pdf'); $this->request = service('incomingrequest', null, false); $this->response = service('response', null, false); @@ -146,10 +146,10 @@ public function testPrepareAbortsIfNativeContentTypeIsNotHtml(): void public function testPrepareAbortsIfNativeContentDispositionIsAttachment(): void { // A file download (even if it is HTML) - NativeHeadersStack::pushMany([ + NativeHeadersStack::$headers = [ 'Content-Type: text/html', 'Content-Disposition: attachment; filename="report.html"', - ]); + ]; $this->request = service('incomingrequest', null, false); $this->response = service('response', null, false); @@ -165,7 +165,7 @@ public function testPrepareAbortsIfNativeContentDispositionIsAttachment(): void public function testPrepareWorksWithNativeHtmlHeader(): void { // Standard scenario where PHP header is text/html - NativeHeadersStack::set('Content-Type: text/html; charset=UTF-8'); + NativeHeadersStack::push('Content-Type: text/html; charset=UTF-8'); $this->request = service('incomingrequest', null, false); $this->response = service('response', null, false); From b4062fbe0f519d723e8ac016026673a2d6aa4357 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Sun, 4 Jan 2026 20:19:53 +0330 Subject: [PATCH 08/11] test: load mock once using setUpBeforeClass() --- tests/system/Debug/ToolbarTest.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/system/Debug/ToolbarTest.php b/tests/system/Debug/ToolbarTest.php index b7b388751994..d701e7753ff7 100644 --- a/tests/system/Debug/ToolbarTest.php +++ b/tests/system/Debug/ToolbarTest.php @@ -24,8 +24,6 @@ use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; -require_once SUPPORTPATH . 'Mock/MockNativeHeaders.php'; - /** * @internal */ @@ -37,6 +35,14 @@ 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(); From 07abd7625851408e93bd01940672d4b42d260525 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 5 Jan 2026 09:30:43 +0330 Subject: [PATCH 09/11] refactor: normalize native headers once for cleaner comparisons Co-authored-by: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> --- system/Debug/Toolbar.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index cf7418acfc20..646af46b8c6a 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -562,7 +562,9 @@ protected function hasNativeHeaderConflict(): bool // 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'))) { + $lowercaseHeader = strtolower($header); + + if (str_starts_with($lowercaseHeader, 'content-type:') && ! str_contains($lowercaseHeader, 'text/html')) { return true; } // File is being downloaded (Attachment) From e523bac31264ae5ad4bb2f9d7d9587ad4e7be9e1 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Mon, 5 Jan 2026 09:35:00 +0330 Subject: [PATCH 10/11] refactor: improve readability of hasNativeHeaderConflict method --- system/Debug/Toolbar.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 646af46b8c6a..7d828ad27d07 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -561,14 +561,12 @@ protected function hasNativeHeaderConflict(): bool // Native Header Inspection foreach (headers_list() as $header) { - // Content-Type is set but is NOT text/html - $lowercaseHeader = strtolower($header); + $lowerHeader = strtolower($header); - if (str_starts_with($lowercaseHeader, 'content-type:') && ! str_contains($lowercaseHeader, 'text/html')) { - return true; - } - // File is being downloaded (Attachment) - if (str_starts_with(strtolower($header), strtolower('Content-Disposition:')) && str_contains(strtolower($header), strtolower('attachment'))) { + $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; } } From b483c8ec196fef7c19364c7ff0d45a93d4ccb917 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Dadashi Date: Mon, 5 Jan 2026 11:25:22 +0330 Subject: [PATCH 11/11] docs: update changelog for Toolbar native header detection fix --- user_guide_src/source/changelogs/v4.7.0.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 85d30224559d..f8e11e1a9608 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -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 ` and :ref:`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 ` 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