Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the Report-To header and correct report-to syntax #17

Merged
merged 7 commits into from
Jul 18, 2024
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ public function configure(): void
```
_Usually you'll define `private const FRAGMENTS = []` and add them in there so it's clear at the beginning what fragments you're adding._

To set the **report to**, we usually use an env var named `CSP_REPORT_TO`. You can also call `$this->reportTo()` in your policies configure func if required (perhaps you want the report URI based on the policy applied).
To set the **report to** url, we usually use an env var named `CSP_REPORT_TO`. The expiry time can also be set using `CSP_REPORT_TO_TTL` this tells the browser how long it should remember the url for.

You can also call `$this->reportTo()` in your policies configure func if required (perhaps you want the report URI based on the policy applied).

Reporting can be sent to multiple urls if required, `CSP_REPORT_TO` supports CSV, or the directive can be used with an array.

To add the policy to the list of applied policies you'll want to add some yaml config:
```yaml
Expand Down
2 changes: 1 addition & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/silverstripe/framework/tests/bootstrap.php" colors="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/silverstripe/cms/tests/bootstrap.php" colors="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
<coverage includeUncoveredFiles="true">
<include>
<directory suffix=".php">src/</directory>
Expand Down
168 changes: 150 additions & 18 deletions src/Policies/Policy.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,28 @@ public function enforce(): self
return $this;
}

/**
* Add reporting directives to the policy, so that violations can be sent to
* the uri defined as CSP_REPORT_TO in the environment.
*
* @param string $uri - the uri to send the reports to, or empty to remove reporting
* @return self
*/
public function reportTo(string $uri): self
{
// if the string is empty, we can assume we need to _remove_ reporting
if (empty($uri)) {
unset($this->directives[Directive::REPORT]);
unset($this->directives[Directive::REPORT_TO]);

return $this;
}

// Add the report-uri directive - this is deprecated, but still supported by most browsers
$this->directives[Directive::REPORT] = [$uri];

// Add the report-to directive - this is the new standard, but not yet supported by all browsers
// the syntax for this will be fixed when the header is added
$this->directives[Directive::REPORT_TO] = [$uri];

return $this;
Expand All @@ -108,6 +127,12 @@ public function addNonceForDirective(string $directive): self
);
}

/**
* Apply the CSP header to the response
*
* @param HTTPResponse $response
* @return void
*/
public function applyTo(HTTPResponse $response)
{
$this->configure();
Expand All @@ -122,10 +147,8 @@ public function applyTo(HTTPResponse $response)
return;
}

$reportTo = Environment::getEnv('CSP_REPORT_TO');
if (!array_key_exists(Directive::REPORT, $this->directives) && $reportTo) {
$this->reportTo($reportTo);
}
// optionally add reporting directives
$this->applyReporting($response);

$response->addHeader($headerName, (string) $this);
$response->addHeader('csp-name', ClassInfo::shortName(static::class));
Expand All @@ -143,7 +166,34 @@ public function __toString()
: "{$directive} {$valueString}";
}

return implode(';', $directives);
return implode('; ', $directives);
}

/*
* Takes an array of `Fragment` implementations and adds them to the policy
*/
public function addFragments(array $fragments): self
{
foreach ($fragments as $fragment) {
call_user_func_array([$fragment, 'addTo'], [$this]);
}

return $this;
}

/**
* If the given value is not an array and not null, wrap it in one.
*
* @param mixed $value
* @return array
*/
public static function wrap($value): array
{
if (is_null($value)) {
return [];
}

return is_array($value) ? $value : [$value];
}

protected function guardAgainstInvalidDirectives(string $directive)
Expand Down Expand Up @@ -196,30 +246,112 @@ protected function sanitizeValue(string $value): string
return $value;
}

/*
* Takes an array of `Fragment` implementations and adds them to the policy
/**
* Add the reporting directives to the policy if the address is set
* as an environment variable.
*
* @param HTTPResponse $response - the response to add the header to
* @return void
*/
public function addFragments(array $fragments): self
private function applyReporting(HTTPResponse $response): void
{
foreach ($fragments as $fragment) {
call_user_func_array([$fragment, 'addTo'], [$this]);
$reportTo = Environment::getEnv('CSP_REPORT_TO');

$hasEnvironmentVariable = !is_null($reportTo) && $reportTo !== false;

// if we have the environment variable, assume we want both directives
if ($hasEnvironmentVariable) {
$hasMultipleUrls = str_contains($reportTo, ',');

// if we are handling multiple urls we need to only add a single directive
if ($hasMultipleUrls) {
$reportToArray = explode(',', $reportTo);
$this->directives[Directive::REPORT_TO] = $reportToArray;
$this->applyReportTo($response);
return;
}

// otherwise add both
$this->reportTo($reportTo);
$this->applyReportTo($response);
return;
}

return $this;
// if we don't have the environment variable,
// check if we have the directives manually set
$hasReportDirective = array_key_exists(Directive::REPORT, $this->directives);
$hasReportToDirective = array_key_exists(Directive::REPORT_TO, $this->directives);

// no directives, no further processing needed
if (!$hasReportDirective && !$hasReportToDirective) {
return;
}

// if the report-to directive is set, we need to add the header and process the value
if ($hasReportToDirective) {
$this->applyReportTo($response);
return;
}
}

/**
* If the given value is not an array and not null, wrap it in one.
* Add the necessary extras for the report-to directive
*
* @param mixed $value
* @return array
* @param HTTPResponse $response - the response to add the header to
* @return void
*/
public static function wrap($value): array
private function applyReportTo(HTTPResponse $response): void
{
if (is_null($value)) {
return [];
$hasReportToDirective = array_key_exists(Directive::REPORT_TO, $this->directives);

// if the environment variable is not set, and the directive is not set, we can't add the header
if (!$hasReportToDirective) {
return;
}

return is_array($value) ? $value : [$value];
// get the directive value
$reportTo = $this->directives[Directive::REPORT_TO];

// if the directive is not set, we can't add the header
if (is_null($reportTo) || $reportTo === false || $reportTo === '') {
return;
}

$endpoints = [];
foreach ($reportTo as $uri) {
// tidy up
$uri = trim($uri);

// if the value is not a url, we can't add the header
if (!filter_var($uri, FILTER_VALIDATE_URL)) {
continue;
}

// if the value is a url, we can use it as the endpoint
$endpoints[] = [
'url' => $uri,
];
}

// if we don't have any endpoints, we can't add the header
if (count($endpoints) === 0) {
return;
}

// set a standard group name to use
$groupName = 'csp-endpoint';

// add the group name to the directive, replacing the invalid urls
$this->directives[Directive::REPORT_TO] = [$groupName];

// set the amount of time the users-browser should store the endpoint
$ttl = Environment::getEnv('CSP_REPORT_TO_TTL') ?: 10886400; // 126 days

// add the reponse header
$response->addHeader('Report-To', json_encode([
'group' => $groupName,
'max_age' => $ttl,
'endpoints' => $endpoints,
], JSON_UNESCAPED_SLASHES));
}
}
Loading
Loading