diff --git a/composer.json b/composer.json index 1fed357..ac38192 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,8 @@ "php": "^7.1|^8", "ext-json": "*", "paragonie/constant_time_encoding": "^2", - "psr/http-message": "^1|^2" + "psr/http-message": "^1|^2", + "opis/json-schema": "^2.3" }, "require-dev": { "phpunit/phpunit": "^7|^8|^9|^10", diff --git a/schema/reportto.json b/schema/reportto.json new file mode 100644 index 0000000..8d48720 --- /dev/null +++ b/schema/reportto.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "$ref": "#/definitions/ReportTo", + "definitions": { + "ReportTo": { + "type": "object", + "additionalProperties": false, + "properties": { + "group": { + "type": "string" + }, + "max_age": { + "type": "integer" + }, + "endpoints": { + "type": "array", + "items": { + "$ref": "#/definitions/Endpoint" + } + }, + "include_subdomains": { + "type": "boolean" + } + }, + "required": [ + "endpoints", + "group", + "max_age" + ], + "title": "ReportTo" + }, + "Endpoint": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "format": "uri", + "qt-uri-protocols": [ + "https" + ] + } + }, + "required": [ + "url" + ], + "title": "Endpoint" + } + } +} diff --git a/src/CSPBuilder.php b/src/CSPBuilder.php index a2bbcd9..9b360f0 100644 --- a/src/CSPBuilder.php +++ b/src/CSPBuilder.php @@ -2,6 +2,9 @@ declare(strict_types=1); namespace ParagonIE\CSPBuilder; +use Opis\JsonSchema\Exceptions\SchemaException; +use Opis\JsonSchema\Helper; +use Opis\JsonSchema\Validator; use ParagonIE\ConstantTime\Base64; use Psr\Http\Message\MessageInterface; use Exception; @@ -56,6 +59,21 @@ class CSPBuilder */ private $compiled = ''; + /** + * @var array + */ + private $reportEndpoints = []; + + /** + * @var string + */ + private $compiledEndpoints = ''; + + /** + * @var bool + */ + private $needsCompileEndpoints = true; + /** * @var bool */ @@ -141,13 +159,14 @@ public function compile(): string if (!is_string($this->policies['report-uri'])) { throw new TypeError('report-uri policy somehow not a string'); } - $compiled [] = 'report-uri ' . $this->enc($this->policies['report-uri'], 'report-uri') . '; '; + $compiled [] = sprintf('report-uri %s; ', $this->enc($this->policies['report-uri']), 'report-uri'); } if (!empty($this->policies['report-to'])) { if (!is_string($this->policies['report-to'])) { throw new TypeError('report-to policy somehow not a string'); } - $compiled []= 'report-to ' . $this->policies['report-to'] . '; '; + // @todo validate this `report-to` target, is in the `report-to` header? + $compiled[] = sprintf('report-to %s; ', $this->policies['report-to']); } if (!empty($this->policies['upgrade-insecure-requests'])) { $compiled []= 'upgrade-insecure-requests'; @@ -155,9 +174,36 @@ public function compile(): string $this->compiled = rtrim(implode('', $compiled), '; '); $this->needsCompile = false; + return $this->compiled; } + public function compileReportEndpoints() + { + if (!empty($this->reportEndpoints) && $this->needsCompileEndpoints) { + // If it's a string, it's probably something like `report-to: key=endpoint + // Do nothing + if (!is_array($this->reportEndpoints)) { + throw new TypeError('Report endpoints is not an array'); + } + if (is_array($this->reportEndpoints)) { + $jsonValidator = new Validator(); + $reportTo = []; + $schema = file_get_contents(__DIR__ . '/../schema/reportto.json'); + foreach ($this->reportEndpoints as $reportEndpoint) { + $reportEndpointAsJSON = \Opis\JsonSchema\Helper::toJSON($reportEndpoint); + $isValid = $jsonValidator->validate($reportEndpointAsJSON, $schema); + if ($isValid->isValid()) { + $reportTo[] = json_encode($reportEndpointAsJSON); + } + + } + $this->compiledEndpoints = rtrim(implode(',', $reportTo)); + } + $this->needsCompileEndpoints = false; + } + } + /** * Add a source to our allow white-list * @@ -266,6 +312,17 @@ public function addDirective(string $key, $value = null): self return $this; } + + /** + * @param array|string $reportEndpoint + * @return void + */ + public function addReportEndpoints(array|string $reportEndpoint): void + { + $this->needsCompileEndpoints = true; + $this->reportEndpoints[] = Helper::toJSON($reportEndpoint); + } + /** * Add a plugin type to be added * @@ -432,6 +489,20 @@ public function getCompiledHeader(): string return $this->compiled; } + /** + * Get the formatted report-to header + * + * @return string + */ + public function getCompiledReportEndpointsHeader(): string + { + if ($this->needsCompileEndpoints) { + $this->compileReportEndpoints(); + } + + return $this->compiledEndpoints; + } + /** * Get an associative array of headers to return. * @@ -443,7 +514,14 @@ public function getHeaderArray(bool $legacy = true): array if ($this->needsCompile) { $this->compile(); } - $return = []; + if ($this->needsCompileEndpoints) { + $this->compileReportEndpoints(); + } + if (!empty($this->compiledEndpoints)) { + $return = [ + 'Report-To' => $this->compiledEndpoints + ]; + } foreach ($this->getHeaderKeys($legacy) as $key) { $return[(string) $key] = $this->compiled; } @@ -465,6 +543,14 @@ public function getRequireHeaders(): array return $headers; } + /** + * @return array|string + */ + public function getReportEndpoints(): array + { + return $this->reportEndpoints; + } + /** * Add a new hash to the existing CSP * @@ -505,6 +591,9 @@ public function injectCSPHeader(MessageInterface $message, bool $legacy = false) if ($this->needsCompile) { $this->compile(); } + if ($this->needsCompileEndpoints) { + $this->compileReportEndpoints(); + } foreach ($this->getRequireHeaders() as $header) { list ($key, $value) = $header; $message = $message->withAddedHeader($key, $value); @@ -512,6 +601,10 @@ public function injectCSPHeader(MessageInterface $message, bool $legacy = false) foreach ($this->getHeaderKeys($legacy) as $key) { $message = $message->withAddedHeader($key, $this->compiled); } + if (!empty($this->compileReportEndpoints())) { + $message = $message->withAddedHeader('report-to', $this->reportTo); + } + return $message; } @@ -586,6 +679,7 @@ public function saveSnippet( ): bool { if ($this->needsCompile) { $this->compile(); + $this->compileReportEndpoints(); } // Are we doing a report-only header? @@ -642,12 +736,18 @@ public function sendCSPHeader(bool $legacy = true): bool if ($this->needsCompile) { $this->compile(); } + if ($this->needsCompileEndpoints) { + $this->compileReportEndpoints(); + } foreach ($this->getRequireHeaders() as $header) { list ($key, $value) = $header; - header($key.': '.$value); + header(sprintf('%s: %s', $key, $value)); } foreach ($this->getHeaderKeys($legacy) as $key) { - header($key.': '.$this->compiled); + header(sprintf('%s: %s', $key, $this->compiled)); + } + if (!empty($this->compiledEndpoints)) { + header(sprintf('report-to: %s', $this->compiledEndpoints)); } return true; } @@ -770,6 +870,31 @@ public function removeDirective(string $key): self return $this; } + /** + * @param array|string $reportEndpoints + * @return void + */ + public function setReportEndpoints(array|string $reportEndpoints): void + { + $this->needsCompileEndpoints = true; + $toJSON = Helper::toJSON($reportEndpoints); + // If there's only one, wrap it in an array, so more can be added + $toJSON = is_array($toJSON) ? $toJSON : [$toJSON]; + $this->reportEndpoints = $toJSON; + } + + + public function removeReportEndpoint(string $key) + { + foreach ($this->reportEndpoints as $idx => $endpoint) { + if ($endpoint->group === $key) { + unset($this->reportEndpoints[$idx]); + // Reset the array keys + $this->reportEndpoints = array_values($this->reportEndpoints); + break; + } + } + } /** * Allow/disallow filesystem: URIs for a given directive * @@ -927,10 +1052,10 @@ public function setReportUri(string $url = ''): self /** * Set the report-to directive to the desired string. * - * @param string $policy + * @param string|array $policy * @return self */ - public function setReportTo(string $policy = ''): self + public function setReportTo($policy = ''): self { $this->policies['report-to'] = $policy; return $this;