diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index c94fed4c8e73..acea6b2b5ad6 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -941,10 +941,6 @@ protected function buildHeaders(ResponseInterface $response) return; } - $response->setHeader('Content-Security-Policy', []); - $response->setHeader('Content-Security-Policy-Report-Only', []); - $response->setHeader('Reporting-Endpoints', []); - if (in_array($this->baseURI, ['', null, []], true)) { $this->baseURI = 'self'; } @@ -967,9 +963,7 @@ protected function buildHeaders(ResponseInterface $response) } } - // Compile our own header strings here since if we just - // append it to the response, it will be joined with - // commas, not semi-colons as we need. + // Compile each generated header value before appending it to the response. if ($this->reportingEndpoints !== []) { $endpoints = []; diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index 5bced590228a..780cb99f31ea 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\TestResponse; @@ -90,15 +91,68 @@ public function testExistence(): void { $this->assertTrue($this->work()); $this->assertHeaderEmitted('Content-Security-Policy:'); + $this->assertHeaderNotEmitted('Content-Security-Policy-Report-Only:'); + $this->assertHeaderNotEmitted('Reporting-Endpoints:'); } #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testReportOnly(): void { - $this->csp->reportOnly(false); + $config = new CSPConfig(); + $config->reportOnly = true; + + Services::injectMock('csp', new ContentSecurityPolicy($config)); + + $this->response = new Response(config(App::class)); + $this->response->pretend(false); + $this->csp = $this->response->getCSP(); + $this->assertTrue($this->work()); - $this->assertHeaderEmitted('Content-Security-Policy:'); + $this->assertHeaderEmitted('Content-Security-Policy-Report-Only:'); + $this->assertHeaderNotEmitted('Content-Security-Policy:'); + $this->assertHeaderNotEmitted('Reporting-Endpoints:'); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testExistingCSPHeaderStringIsPreserved(): void + { + $this->response->setHeader('Content-Security-Policy', "frame-src 'none'"); + + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy:'); + $this->assertIsString($header); + $this->assertStringContainsString("frame-src 'none', base-uri 'self'", $header); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testExistingCSPHeaderArrayIsPreserved(): void + { + $this->response->setHeader('Content-Security-Policy', [ + "frame-src 'none'", + "media-src 'none'", + ]); + + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy:'); + $this->assertIsString($header); + $this->assertStringContainsString("frame-src 'none', media-src 'none', base-uri 'self'", $header); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testExistingReportOnlyHeaderIsPreserved(): void + { + $this->response->setHeader('Content-Security-Policy-Report-Only', "frame-src 'none'"); + + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy-Report-Only:'); + $this->assertSame("Content-Security-Policy-Report-Only: frame-src 'none'", $header); } #[PreserveGlobalState(false)] @@ -609,6 +663,23 @@ public function testReportTo(): void $this->assertSame('Reporting-Endpoints: default="http://example.com/csp-reports"', $header); } + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testExistingReportingEndpointsHeaderIsPreserved(): void + { + $this->response->setHeader('Reporting-Endpoints', 'existing="http://example.com/existing-reports"'); + $this->csp->addReportingEndpoints(['default' => 'http://example.com/csp-reports']); + $this->csp->setReportToEndpoint('default'); + + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Reporting-Endpoints:'); + $this->assertSame( + 'Reporting-Endpoints: existing="http://example.com/existing-reports", default="http://example.com/csp-reports"', + $header, + ); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testReportToMultipleEndpoints(): void diff --git a/user_guide_src/source/changelogs/v4.7.5.rst b/user_guide_src/source/changelogs/v4.7.5.rst index c4bb7900e07a..964133046c38 100644 --- a/user_guide_src/source/changelogs/v4.7.5.rst +++ b/user_guide_src/source/changelogs/v4.7.5.rst @@ -30,6 +30,7 @@ Deprecations Bugs Fixed ********** +- **Content Security Policy:** Fixed a bug where empty ``Content-Security-Policy``, ``Content-Security-Policy-Report-Only``, and ``Reporting-Endpoints`` response headers were generated when no corresponding values existed. - **Logger:** Fixed a bug where interpolating a log message with array or non-stringable context values could raise PHP warnings or errors. See the repo's