From 063d6559cf2ed02a30e3a383bac5d7bdcf571c1c Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 1 Feb 2026 23:01:16 +0800 Subject: [PATCH] feat: add support for CSP3 `report-to` directive --- app/Config/ContentSecurityPolicy.php | 5 + system/HTTP/ContentSecurityPolicy.php | 118 +++++++++++++++--- .../system/HTTP/ContentSecurityPolicyTest.php | 78 ++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 3 +- user_guide_src/source/outgoing/csp.rst | 16 +++ user_guide_src/source/outgoing/csp/015.php | 14 +++ 6 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 user_guide_src/source/outgoing/csp/015.php diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php index 9180d11c79d9..f64a9af22b0a 100644 --- a/app/Config/ContentSecurityPolicy.php +++ b/app/Config/ContentSecurityPolicy.php @@ -30,6 +30,11 @@ class ContentSecurityPolicy extends BaseConfig */ public ?string $reportURI = null; + /** + * Specifies a reporting endpoint to which violation reports ought to be sent. + */ + public ?string $reportTo = null; + /** * Instructs user agents to rewrite URL schemes, changing * HTTP to HTTPS. This directive is for websites with diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index 52d3d99a7d02..04d93365af25 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -13,6 +13,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Exceptions\InvalidArgumentException; use Config\App; use Config\ContentSecurityPolicy as ContentSecurityPolicyConfig; @@ -60,6 +61,7 @@ class ContentSecurityPolicy protected array $directives = [ ...self::DIRECTIVES_ALLOWING_SOURCE_LISTS, 'report-uri' => 'reportURI', + 'report-to' => 'reportTo', ]; /** @@ -179,6 +181,12 @@ class ContentSecurityPolicy */ protected $reportURI; + /** + * The `report-to` directive specifies a named group in a Reporting API + * endpoint to which the user agent sends reports about policy violation. + */ + protected ?string $reportTo = null; + // -------------------------------------------------------------- // CSP Level 3 Directives // -------------------------------------------------------------- @@ -333,6 +341,13 @@ class ContentSecurityPolicy */ protected $CSPEnabled = false; + /** + * Map of reporting endpoints to their URLs. + * + * @var array + */ + private array $reportingEndpoints = []; + /** * Stores our default values from the Config file. */ @@ -657,24 +672,6 @@ public function addPluginType($mime, ?bool $explicitReporting = null) return $this; } - /** - * Specifies a URL where a browser will send reports when a content - * security policy is violated. - * - * @see http://www.w3.org/TR/CSP/#directive-report-uri - * - * @param string $uri URL to send reports. Set `''` if you want to remove - * this directive at runtime. - * - * @return $this - */ - public function setReportURI(string $uri) - { - $this->reportURI = $uri; - - return $this; - } - /** * Adds a new value to the `sandbox` directive. * @@ -805,6 +802,66 @@ public function upgradeInsecureRequests(bool $value = true) return $this; } + /** + * Specifies a URL where a browser will send reports when a content + * security policy is violated. + * + * @see http://www.w3.org/TR/CSP/#directive-report-uri + * + * @param string $uri URL to send reports. Set `''` if you want to remove + * this directive at runtime. + * + * @return $this + */ + public function setReportURI(string $uri) + { + $this->reportURI = $uri; + + return $this; + } + + /** + * Specifies a named group in a Reporting API endpoint to which the user + * agent sends reports about policy violation. + * + * @see https://www.w3.org/TR/CSP/#directive-report-to + * + * @param string $endpoint The name of the reporting endpoint. Set `''` if you + * want to remove this directive at runtime. + */ + public function setReportToEndpoint(string $endpoint): static + { + if ($endpoint === '') { + $this->reportURI = null; + $this->reportTo = null; + + return $this; + } + + if (! array_key_exists($endpoint, $this->reportingEndpoints)) { + throw new InvalidArgumentException(sprintf('The reporting endpoint "%s" has not been defined.', $endpoint)); + } + + $this->reportURI = $this->reportingEndpoints[$endpoint]; // for BC with browsers that do not support `report-to` + $this->reportTo = $endpoint; + + return $this; + } + + /** + * Adds reporting endpoints to the `Reporting-Endpoints` header. + * + * @param array $endpoint + */ + public function addReportingEndpoints(array $endpoint): static + { + foreach ($endpoint as $name => $url) { + $this->reportingEndpoints[$name] = $url; + } + + return $this; + } + /** * DRY method to add an string or array to a class property. * @@ -864,6 +921,7 @@ protected function buildHeaders(ResponseInterface $response) { $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'; @@ -878,6 +936,10 @@ protected function buildHeaders(ResponseInterface $response) continue; } + if ($name === 'report-to' && (string) $this->reportTo === '') { + continue; + } + if ($this->{$property} !== null) { $this->addToHeader($name, $this->{$property}); } @@ -886,6 +948,17 @@ 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. + if ($this->reportingEndpoints !== []) { + $endpoints = []; + + foreach ($this->reportingEndpoints as $name => $url) { + $endpoints[] = trim("{$name}=\"{$url}\""); + } + + $response->appendHeader('Reporting-Endpoints', implode(', ', $endpoints)); + $this->reportingEndpoints = []; + } + if ($this->tempHeaders !== []) { $header = []; @@ -905,7 +978,7 @@ protected function buildHeaders(ResponseInterface $response) $header = []; foreach ($this->reportOnlyHeaders as $name => $value) { - $header[] = "{$name} {$value}"; + $header[] = trim("{$name} {$value}"); } $response->appendHeader('Content-Security-Policy-Report-Only', implode('; ', $header)); @@ -970,6 +1043,13 @@ public function clearDirective(string $directive): void return; } + if ($directive === 'report-to') { + $this->reportURI = null; + $this->reportTo = null; + + return; + } + $this->{$this->directives[$directive]} = []; } } diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index 3985dba8bda7..af9638b6a8ba 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\TestResponse; use Config\App; @@ -588,6 +589,77 @@ public function testRemoveReportURI(): void $this->assertStringNotContainsString('report-uri', $header); } + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testReportTo(): void + { + $this->csp->reportOnly(false); + $this->csp->addReportingEndpoints(['default' => 'http://example.com/csp-reports']); + $this->csp->setReportToEndpoint('default'); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains('report-uri http://example.com/csp-reports', $this->getCspDirectives($header)); + $this->assertContains('report-to default', $this->getCspDirectives($header)); + + $this->assertHeaderEmitted('Reporting-Endpoints'); + $header = $this->getHeaderEmitted('Reporting-Endpoints'); + $this->assertIsString($header); + $this->assertSame('Reporting-Endpoints: default="http://example.com/csp-reports"', $header); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testReportToMultipleEndpoints(): void + { + $this->csp->reportOnly(false); + $this->csp->addReportingEndpoints([ + 'endpoint1' => 'http://example.com/csp-reports-1', + 'endpoint2' => 'http://example.com/csp-reports-2', + ]); + $this->csp->setReportToEndpoint('endpoint2'); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains('report-uri http://example.com/csp-reports-2', $this->getCspDirectives($header)); + $this->assertContains('report-to endpoint2', $this->getCspDirectives($header)); + + $this->assertHeaderEmitted('Reporting-Endpoints'); + $header = $this->getHeaderEmitted('Reporting-Endpoints'); + $this->assertIsString($header); + $this->assertSame( + 'Reporting-Endpoints: endpoint1="http://example.com/csp-reports-1", endpoint2="http://example.com/csp-reports-2"', + $header, + ); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testCannotSetReportToWithoutEndpoints(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The reporting endpoint "nonexistent-endpoint" has not been defined.'); + + $this->csp->setReportToEndpoint('nonexistent-endpoint'); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testRemoveReportTo(): void + { + $this->csp->addReportingEndpoints(['default' => 'http://example.com/csp-reports']); + $this->csp->setReportToEndpoint('default'); + $this->csp->setReportToEndpoint(''); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertStringNotContainsString('report-uri', $header); + $this->assertStringNotContainsString('report-to', $header); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testSandboxEmptyFlag(): void @@ -840,15 +912,20 @@ public function testHeaderScriptNonceEmittedOnceGetScriptNonceCalled(): void $this->assertStringContainsString("script-src 'self' 'nonce-", $header); } + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] public function testClearDirective(): void { $this->csp->addFontSrc('fonts.example.com'); $this->csp->addStyleSrc('css.example.com'); $this->csp->setReportURI('http://example.com/csp/reports'); + $this->csp->addReportingEndpoints(['default' => 'http://example.com/csp/reports']); + $this->csp->setReportToEndpoint('default'); $this->csp->clearDirective('fonts-src'); // intentional wrong directive $this->csp->clearDirective('style-src'); $this->csp->clearDirective('report-uri'); + $this->csp->clearDirective('report-to'); $this->csp->finalize($this->response); @@ -858,5 +935,6 @@ public function testClearDirective(): void $this->assertContains('font-src fonts.example.com', $directives); $this->assertNotContains('style-src css.example.com', $directives); $this->assertNotContains('report-uri http://example.com/csp/reports', $directives); + $this->assertNotContains('report-to default', $directives); } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index b0a4b591b2c1..4cb009fe4299 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -337,6 +337,7 @@ HTTP Content Security Policy ----------------------- +- The ``script-src`` and ``style-src`` directives can now use SHA-256, SHA-384, and SHA-512 digests as their source expressions. - Added support for the new CSP Level 3 keywords: - ``'strict-dynamic'`` - ``'unsafe-hashes'`` @@ -347,8 +348,8 @@ Content Security Policy - ``'report-sha256'`` - ``'report-sha384'`` - ``'report-sha512'`` -- Hash values for CSP ``script-src`` and ``style-src`` directives can now use SHA-256, SHA-384, and SHA-512 digests. - Added support for the following CSP Level 3 directives: + - ``report-to`` - ``script-src-elem`` - ``script-src-attr`` - ``style-src-elem`` diff --git a/user_guide_src/source/outgoing/csp.rst b/user_guide_src/source/outgoing/csp.rst index e8d25710ddac..0c62f3cee56d 100644 --- a/user_guide_src/source/outgoing/csp.rst +++ b/user_guide_src/source/outgoing/csp.rst @@ -89,6 +89,22 @@ that youtube.com was allowed, and then provide several allowed but reported sour .. literalinclude:: csp/013.php +Reporting Directives +==================== + +To specify the URL you want the reports to be sent to, you can use the ``setReportURI()`` method. + +.. versionadded:: 4.7.0 + +CSP Level 3 deprecates the ``report-uri`` directive in favor of ``report-to``. Therefore, you can +use the ``setReportToEndpoint()`` method to set the reporting endpoint for CSP reports. Before adding +this directive, make sure the reporting endpoints are already defined using the ``addReportingEndpoints()`` method. + +.. literalinclude:: csp/015.php + +For backward compatibility with browsers that do not support the ``report-to`` directive, CodeIgniter4 will also +set the ``report-uri`` directive when you use the ``setReportToEndpoint()`` method. + .. _csp-clear-directives: Clear Directives diff --git a/user_guide_src/source/outgoing/csp/015.php b/user_guide_src/source/outgoing/csp/015.php new file mode 100644 index 000000000000..979c72f3d16c --- /dev/null +++ b/user_guide_src/source/outgoing/csp/015.php @@ -0,0 +1,14 @@ +response->getCSP(); + +$csp->setReportURI('https://example.com/csp-reports'); + +// Starting in v4.7.0, you can use the setReportToEndpoint() method +// to set the reporting endpoint for CSP reports +$csp->addReportingEndpoints([ + 'default' => 'https://example.com/csp-reports', + 'reports' => 'https://example.com/other-csp-reports', +]); +$csp->setReportToEndpoint('default');