diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index f15a85df32d9..8a4af8f77363 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -429,6 +429,14 @@ public function finalize(ResponseInterface $response) { if ($this->autoNonce) { $this->generateNonces($response); + } else { + // If we're not auto-generating nonces, we should remove any nonce placeholders from the body to prevent them from being rendered. + $body = (string) $response->getBody(); + + if ($body !== '') { + $body = str_replace([$this->styleNonceTag, $this->scriptNonceTag], '', $body); + $response->setBody($body); + } } $this->buildHeaders($response); @@ -905,6 +913,10 @@ protected function generateNonces(ResponseInterface $response) $pattern = sprintf('/(%s|%s)/', preg_quote($this->styleNonceTag, '/'), preg_quote($this->scriptNonceTag, '/')); $body = preg_replace_callback($pattern, function ($match) use ($jsonEscape): string { + if (! $this->enabled()) { + return ''; + } + $nonce = $match[0] === $this->styleNonceTag ? $this->getStyleNonce() : $this->getScriptNonce(); $attr = 'nonce="' . $nonce . '"'; @@ -923,6 +935,10 @@ protected function generateNonces(ResponseInterface $response) */ protected function buildHeaders(ResponseInterface $response) { + if (! $this->enabled()) { + return; + } + $response->setHeader('Content-Security-Policy', []); $response->setHeader('Content-Security-Policy-Report-Only', []); $response->setHeader('Reporting-Endpoints', []); diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index 211663bc7987..3063e44116fb 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -367,11 +367,7 @@ public function send() { // If we're enforcing a Content Security Policy, // we need to give it a chance to build out it's headers. - if ($this->CSP->enabled()) { - $this->CSP->finalize($this); - } else { - $this->body = str_replace(['{csp-style-nonce}', '{csp-script-nonce}'], '', $this->body ?? ''); - } + $this->CSP->finalize($this); $this->sendHeaders(); $this->sendCookies(); diff --git a/tests/system/HTTP/ResponseTest.php b/tests/system/HTTP/ResponseTest.php index 84408df0e5a5..048da85beea9 100644 --- a/tests/system/HTTP/ResponseTest.php +++ b/tests/system/HTTP/ResponseTest.php @@ -24,6 +24,7 @@ use DateTimeZone; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; /** * @internal @@ -577,4 +578,156 @@ public function testPretendOutput(): void $this->assertSame('Happy days', $actual); } + + public function testSendRemovesDefaultNoncePlaceholdersWhenCSPDisabled(): void + { + $config = new App(); + $config->CSPEnabled = false; + + $response = new Response($config); + $response->pretend(true); + + $body = ''; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // Nonce placeholders should be removed when CSP is disabled + $this->assertIsString($actual); + $this->assertStringNotContainsString('{csp-script-nonce}', $actual); + $this->assertStringNotContainsString('{csp-style-nonce}', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + } + + public function testSendRemovesCustomNoncePlaceholdersWhenCSPDisabled(): void + { + $appConfig = new App(); + $appConfig->CSPEnabled = false; + + // Create custom CSP config with custom nonce tags + $cspConfig = new \Config\ContentSecurityPolicy(); + $cspConfig->scriptNonceTag = '{custom-script-tag}'; + $cspConfig->styleNonceTag = '{custom-style-tag}'; + + $response = new Response($appConfig); + $response->pretend(true); + + // Inject the custom CSP config + $reflection = new ReflectionClass($response); + $cspProperty = $reflection->getProperty('CSP'); + $cspProperty->setValue($response, new ContentSecurityPolicy($cspConfig)); + + $body = ''; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // Custom nonce placeholders should be removed when CSP is disabled + $this->assertIsString($actual); + $this->assertStringNotContainsString('{custom-script-tag}', $actual); + $this->assertStringNotContainsString('{custom-style-tag}', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + } + + public function testSendNoEffectWhenBodyEmptyAndCSPDisabled(): void + { + $config = new App(); + $config->CSPEnabled = false; + + $response = new Response($config); + $response->pretend(true); + + $body = ''; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + $this->assertIsString($actual); + $this->assertSame('', $actual); + } + + public function testSendNoEffectWithNoPlaceholdersAndCSPDisabled(): void + { + $config = new App(); + $config->CSPEnabled = false; + + $response = new Response($config); + $response->pretend(true); + + $body = '
No placeholders here
'; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // Body should be unchanged when there are no placeholders and CSP is disabled + $this->assertIsString($actual); + $this->assertSame($body, $actual); + } + + public function testSendRemovesMultiplePlaceholdersWhenCSPDisabled(): void + { + $config = new App(); + $config->CSPEnabled = false; + + $response = new Response($config); + $response->pretend(true); + + $body = ''; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // All nonce placeholders should be removed when CSP is disabled + $this->assertIsString($actual); + $this->assertStringNotContainsString('{csp-script-nonce}', $actual); + $this->assertStringNotContainsString('{csp-style-nonce}', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + } + + public function testSendRemovesPlaceholdersWhenBothCSPAndAutoNonceAreDisabled(): void + { + $appConfig = new App(); + $appConfig->CSPEnabled = false; + + // Create custom CSP config with custom nonce tags + $cspConfig = new \Config\ContentSecurityPolicy(); + $cspConfig->autoNonce = false; + + $response = new Response($appConfig); + $response->pretend(true); + + // Inject the custom CSP config + $reflection = new ReflectionClass($response); + $cspProperty = $reflection->getProperty('CSP'); + $cspProperty->setValue($response, new ContentSecurityPolicy($cspConfig)); + + $body = ''; + $response->setBody($body); + + ob_start(); + $response->send(); + $actual = ob_get_clean(); + + // Custom nonce placeholders should be removed when CSP is disabled + $this->assertIsString($actual); + $this->assertStringNotContainsString('{csp-script-nonce}', $actual); + $this->assertStringNotContainsString('{csp-style-nonce}', $actual); + $this->assertStringContainsString('', $actual); + $this->assertStringContainsString('', $actual); + } } diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 8a94a731a413..c3b872c8611b 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -32,6 +32,7 @@ Deprecations Bugs Fixed ********** +- **ContentSecurityPolicy:** Fixed a bug where custom CSP tags were not removed from generated HTML when CSP was disabled. The method now ensures that all custom CSP tags are removed from the generated HTML. - **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty.