From 74c4afaf95b3563b33d3859c95357e2a008a6ea8 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 13 Oct 2026 21:11:00 +0200 Subject: [PATCH] fix: normalize string values and keys in ContentSecurityPolicy::addOption() --- system/HTTP/ContentSecurityPolicy.php | 36 ++++++++++- .../system/HTTP/ContentSecurityPolicyTest.php | 64 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.6.rst | 1 + 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index dfea52a62772..60b263e83147 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -878,14 +878,34 @@ protected function addOption($options, string $target, ?bool $explicitReporting { // Ensure we have an array to work with... if (is_string($this->{$target})) { - $this->{$target} = [$this->{$target} => $this->reportOnly]; + $targetValue = trim($this->{$target}); + + $this->{$target} = []; + + if ($targetValue !== '') { + foreach (preg_split('/\s+/', $targetValue) as $opt) { + $this->{$target}[$opt] = $this->reportOnly; + } + } + } elseif (! is_array($this->{$target})) { + $this->{$target} = []; } $options = is_array($options) ? $options : [$options]; foreach ($options as $option) { + $option = (string) $option; + + if ($option === '' && $target !== 'sandbox') { + continue; + } + $this->{$target}[$option] = $explicitReporting ?? $this->reportOnly; } + + if ($target === 'sandbox' && count($this->{$target}) > 1 && isset($this->{$target}[''])) { + unset($this->{$target}['']); + } } /** @@ -1013,13 +1033,25 @@ protected function buildHeaders(ResponseInterface $response) protected function addToHeader(string $name, $values = null) { if (is_string($values)) { - $values = [$values => $this->reportOnly]; + $valueStr = trim($values); + + if ($valueStr === '') { + return; + } + + $values = []; + + foreach (preg_split('/\s+/', $valueStr) as $val) { + $values[$val] = $this->reportOnly; + } } $sources = []; $reportSources = []; foreach ($values as $value => $reportOnly) { + $value = (string) $value; + if ( in_array($value, $this->validSources, true) || str_starts_with($value, 'nonce-') diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index 780cb99f31ea..426166509cf8 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -756,6 +756,70 @@ public function testSandboxFlags(): void $this->assertContains('sandbox allow-popups allow-top-navigation', $this->getCspDirectives($header)); } + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSandboxEmptyFlagThenFlags(): void + { + $this->csp->reportOnly(false); + $this->csp->addSandbox(''); + $this->csp->addSandbox('allow-scripts'); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains('sandbox allow-scripts', $this->getCspDirectives($header)); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddOptionWithSpaceSeparatedString(): void + { + $config = new CSPConfig(); + $config->baseURI = 'self example.com'; + + $csp = new ContentSecurityPolicy($config); + + $response = new Response(config(App::class)); + $response->pretend(true); + + $csp->addBaseURI('another.com'); + $csp->finalize($response); + + $directives = $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy')); + $this->assertContains("base-uri 'self' example.com another.com", $directives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddOptionWithEmptyStringTarget(): void + { + $config = new CSPConfig(); + $config->baseURI = ''; + + $csp = new ContentSecurityPolicy($config); + + $response = new Response(config(App::class)); + $response->pretend(true); + + $csp->addBaseURI('example.com'); + $csp->finalize($response); + + $directives = $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy')); + $this->assertContains('base-uri example.com', $directives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddOptionNumericString(): void + { + $this->csp->addBaseURI('0'); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains('base-uri 0', $this->getCspDirectives($header)); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testUpgradeInsecureRequests(): void diff --git a/user_guide_src/source/changelogs/v4.7.6.rst b/user_guide_src/source/changelogs/v4.7.6.rst index 360221806c4f..79747284164f 100644 --- a/user_guide_src/source/changelogs/v4.7.6.rst +++ b/user_guide_src/source/changelogs/v4.7.6.rst @@ -31,6 +31,7 @@ Bugs Fixed ********** - **Commands:** Fixed a bug where generator commands did not sort the imports of a template with CRLF line endings. +- **HTTP:** Fixed a bug in ``ContentSecurityPolicy::addOption()`` where converting string directive values to array keys resulted in invalid entries for empty strings, unquoted values for space-separated lists, and ``TypeError`` for numeric strings. - **Security:** Fixed a bug where a valid JSON request body that is not an object (for example a JSON array) was corrupted after CSRF verification when the token was sent via the ``X-CSRF-TOKEN`` header.