From e0118484d96e8d2edff4d39a13e02a1b356d310d Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 8 Feb 2026 20:13:58 +0100 Subject: [PATCH 1/3] fix: escape CSP nonce attributes in JSON responses --- system/HTTP/ContentSecurityPolicy.php | 8 +++-- tests/system/Debug/ExceptionHandlerTest.php | 33 ++++++++++++++++++ .../system/HTTP/ContentSecurityPolicyTest.php | 34 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 2 ++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index 04d93365af25..f15a85df32d9 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -898,13 +898,17 @@ protected function generateNonces(ResponseInterface $response) return; } + // Escape quotes for JSON responses to prevent corrupting the JSON body + $jsonEscape = str_contains($response->getHeaderLine('Content-Type'), 'json'); + // Replace style and script placeholders with nonces $pattern = sprintf('/(%s|%s)/', preg_quote($this->styleNonceTag, '/'), preg_quote($this->scriptNonceTag, '/')); - $body = preg_replace_callback($pattern, function ($match): string { + $body = preg_replace_callback($pattern, function ($match) use ($jsonEscape): string { $nonce = $match[0] === $this->styleNonceTag ? $this->getStyleNonce() : $this->getScriptNonce(); + $attr = 'nonce="' . $nonce . '"'; - return "nonce=\"{$nonce}\""; + return $jsonEscape ? str_replace('"', '\\"', $attr) : $attr; }, $body); $response->setBody($body); diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index a1958be5f36b..6b6dc3e41e4f 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -16,9 +16,12 @@ use App\Controllers\Home; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\Response; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\IniTestTrait; use CodeIgniter\Test\StreamFilterTrait; +use Config\App; use Config\Exceptions as ExceptionsConfig; use Config\Services; use PHPUnit\Framework\Attributes\Group; @@ -40,6 +43,8 @@ protected function setUp(): void parent::setUp(); $this->handler = new ExceptionHandler(new ExceptionsConfig()); + + $this->resetServices(); } public function testDetermineViewsPageNotFoundException(): void @@ -386,4 +391,32 @@ public function testSanitizeDataWithScalars(): void $this->assertFalse($sanitizeData(false)); $this->assertNull($sanitizeData(null)); } + + public function testHandleJsonResponseWithCSPEnabledProducesValidJson(): void + { + $config = config(App::class); + $config->CSPEnabled = true; + + /** @var IncomingRequest $request */ + $request = service('incomingrequest', $config, false); + $request->setHeader('accept', 'application/json'); + $response = new Response($config); + $response->pretend(); + + $exception = new RuntimeException('Test exception'); + + ob_start(); + $this->handler->handle($exception, $request, $response, 500, EXIT_ERROR); + $output = ob_get_clean(); + + $json = json_decode($output); + $this->assertNotNull($json); + + // Nonce placeholders must not appear in the output + $this->assertStringNotContainsString('{csp-style-nonce}', (string) $output); + $this->assertStringNotContainsString('{csp-script-nonce}', (string) $output); + + // The nonce attribute values should be properly JSON-escaped + $this->assertMatchesRegularExpression('/nonce=\\\\"[A-Za-z0-9+\/=]+\\\\"/', $output); + } } diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index af9638b6a8ba..cbde9db2bd32 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -937,4 +937,38 @@ public function testClearDirective(): void $this->assertNotContains('report-uri http://example.com/csp/reports', $directives); $this->assertNotContains('report-to default', $directives); } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testGenerateNoncesReplacesPlaceholdersInHtml(): void + { + $body = ''; + + $this->response->setBody($body); + $this->csp->finalize($this->response); + + $result = $this->response->getBody(); + + $this->assertMatchesRegularExpression('/