Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/Config/ContentSecurityPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 99 additions & 19 deletions system/HTTP/ContentSecurityPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace CodeIgniter\HTTP;

use CodeIgniter\Exceptions\InvalidArgumentException;
use Config\App;
use Config\ContentSecurityPolicy as ContentSecurityPolicyConfig;

Expand Down Expand Up @@ -60,6 +61,7 @@ class ContentSecurityPolicy
protected array $directives = [
...self::DIRECTIVES_ALLOWING_SOURCE_LISTS,
'report-uri' => 'reportURI',
'report-to' => 'reportTo',
];

/**
Expand Down Expand Up @@ -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
// --------------------------------------------------------------
Expand Down Expand Up @@ -333,6 +341,13 @@ class ContentSecurityPolicy
*/
protected $CSPEnabled = false;

/**
* Map of reporting endpoints to their URLs.
*
* @var array<string, string>
*/
private array $reportingEndpoints = [];

/**
* Stores our default values from the Config file.
*/
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<string, string> $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.
*
Expand Down Expand Up @@ -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';
Expand All @@ -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});
}
Expand All @@ -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 = [];

Expand All @@ -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));
Expand Down Expand Up @@ -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]} = [];
}
}
78 changes: 78 additions & 0 deletions tests/system/HTTP/ContentSecurityPolicyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace CodeIgniter\HTTP;

use CodeIgniter\Exceptions\InvalidArgumentException;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\TestResponse;
use Config\App;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand All @@ -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);
}
}
3 changes: 2 additions & 1 deletion user_guide_src/source/changelogs/v4.7.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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'``
Expand All @@ -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``
Expand Down
16 changes: 16 additions & 0 deletions user_guide_src/source/outgoing/csp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions user_guide_src/source/outgoing/csp/015.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

// get the CSP instance
$csp = $this->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');
Loading