diff --git a/README.md b/README.md index 9124bea..976ae2b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ To set the **report to** url, we usually use an env var named `CSP_REPORT_TO`. T 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 Silverstripe\CSP\CSPMiddleware: diff --git a/src/Policies/Policy.php b/src/Policies/Policy.php index aca13ca..4f9ccb4 100644 --- a/src/Policies/Policy.php +++ b/src/Policies/Policy.php @@ -261,6 +261,17 @@ private function applyReporting(HTTPResponse $response): void // 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; @@ -298,17 +309,41 @@ private function applyReportTo(HTTPResponse $response): void return; } - // set a standard group name to use - $groupName = 'csp-endpoint'; + // 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 directive is set incorrectly as a url, use it for the endpoint instead - if (filter_var($this->directives[Directive::REPORT_TO][0], FILTER_VALIDATE_URL)) { - $reportTo = $this->directives[Directive::REPORT_TO][0]; + // 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, + ]; + } - // and set it correctly - $this->directives[Directive::REPORT_TO] = [$groupName]; + // 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 @@ -316,11 +351,7 @@ private function applyReportTo(HTTPResponse $response): void $response->addHeader('Report-To', json_encode([ 'group' => $groupName, 'max_age' => $ttl, - 'endpoints' => [ - [ - 'url' => $reportTo, - ], - ], + 'endpoints' => $endpoints, ], JSON_UNESCAPED_SLASHES)); } } diff --git a/tests/PolicyTest.php b/tests/PolicyTest.php index 7154193..0e5c59a 100644 --- a/tests/PolicyTest.php +++ b/tests/PolicyTest.php @@ -291,6 +291,112 @@ public function testAReportToCanBeSetWithoutReportURI(): void ); } + /** + * Check the reporting endpoint can be set from the environment variable + */ + public function testMultipleReportURICanBeSetFromEnvironmentVariable(): void + { + [$request, $response] = $this->getRequestResponse(); + /** @var Policy $policy */ + $policy = Injector::inst()->get(CMS::class); + + $reportTo = 'https://example.com,http://example.org,https://example.net'; + Environment::setEnv('CSP_REPORT_TO', $reportTo); + Environment::setEnv('CSP_REPORT_ONLY', 'enabled'); + + // apply the policy + $policy->applyTo($response); + + // check the header + $this->assertNull($response->getHeader('Content-Security-Policy')); + $this->assertNotNull($response->getHeader('Content-Security-Policy-Report-Only')); + + // the report-uri directive only supports a single address, + // so we should not expect to see it + $this->assertStringNotContainsString( + 'report-uri', + $response->getHeader('Content-Security-Policy-Report-Only') + ); + + // check the report-to directive + $this->assertStringContainsString( + 'report-to csp-endpoint', + $response->getHeader('Content-Security-Policy-Report-Only') + ); + + // check the Report-To header + $this->assertNotNull($response->getHeader('Report-To')); + + // convert the comma separated list into an array + $urls = explode(',', $reportTo); + $endpoints = []; + foreach ($urls as $url) { + $endpoints[] = ['url' => trim($url)]; + } + + $this->assertStringContainsString( + sprintf( + '{"group":"csp-endpoint","max_age":10886400,"endpoints":%s}', + json_encode($endpoints, JSON_UNESCAPED_SLASHES) + ), + $response->getHeader('Report-To') + ); + } + + /** + * Check the reporting endpoint can be set from the environment variable + */ + public function testMultipleReportURICanBeSetFromCode(): void + { + [$request, $response] = $this->getRequestResponse(); + /** @var Policy $policy */ + $policy = Injector::inst()->get(CMS::class); + + $urls = [ + 'https://example.com', + 'http://example.org', + 'https://example.net', + ]; + $policy->addDirective(Directive::REPORT_TO, $urls); + + // apply the policy + $policy->applyTo($response); + + // check the header + $this->assertNotNull($response->getHeader('Content-Security-Policy')); + $this->assertNull($response->getHeader('Content-Security-Policy-Report-Only')); + + // the report-uri directive only supports a single address, + // so we should not expect to see it + $this->assertStringNotContainsString( + 'report-uri', + $response->getHeader('Content-Security-Policy') + ); + + // check the report-to directive + $this->assertStringContainsString( + 'report-to csp-endpoint', + $response->getHeader('Content-Security-Policy') + ); + + // check the Report-To header + $this->assertNotNull($response->getHeader('Report-To')); + + // convert the comma separated list into an array + $endpoints = []; + foreach ($urls as $url) { + $endpoints[] = ['url' => trim($url)]; + } + + $this->assertStringContainsString( + sprintf( + '{"group":"csp-endpoint","max_age":10886400,"endpoints":%s}', + json_encode($endpoints, JSON_UNESCAPED_SLASHES) + ), + $response->getHeader('Report-To') + ); + } + public function testIsCanUseMultipleValuesForTheSameDirective(): void { $policy = new class extends Policy {