PHP Classes

File: src/CSPBuilder.php

Recommend this page to a friend!
  Classes of Scott Arciszewski   PHP CSP Header Builder   src/CSPBuilder.php   Download  
File: src/CSPBuilder.php
Role: Class source
Content type: text/plain
Description: Class source
Class: PHP CSP Header Builder
Generate Content Security Policy headers
Author: By
Last change: add updates for PHP 8.4
Update CI, fix Psalm errors
When building from aggregated violations, the list of allowed domains will contain many duplicate entries. This is the most simple fix to remove the duplication

Preferably, a public function hasSource(string $directive, string $path): bool would be available to prevent adding in the first place, but array_unique will do the trick for now
Suppress Psalm complaint
Fix PHP 7.1 support
Merge pull request #70 from Firesphere/hans/report-to-header

Add PoC of report-to header
Add PoC of report-to header
fix support for script-src-{elem|attr}, add support for style-src-{elem|attr}

Signed-off-by: Gary Lockett <[email protected]>
Prevent semicolons and CRLFs from being injected in report-uri
Merge pull request #66 from fritzmg/remove-trailing-semicolon

Remove trailing semicolon
remove trailing semicolon
Add 'url' type value for report-uri
Don't allow Semicolon or CRLF injection

CSP-Builder is a developer tool. It is not meant to be used with user input.

However, the ability to inject CSP directives or additional headers violates the principle of least astonishment.

This was reported via user demonia on HackerOne.
Add unit test, fix pasto
Add unsafe-hashes, fix CS
Fix style
Handle undefined array keys
Add support for script-src-elem and script-src-attr

Fixes #52
Prevent duplicates. Fixes #23
Export policies to JSON.

See #39
Merge pull request #51 from timmit-nl/master

Add setReportSample. See #46.
tabs converted to spaces
add setReportSample. See #46
Use newer PHPUnit
Add a removeDirective function to remove directives
Merge pull request #43 from iangcarroll/feature/sandbox

Add support for the sandbox attribute.
Use elseif instead of else if.
Add support for the sandbox attribute.
Date: 5 months ago
Size: 39,020 bytes
 

Contents

Class file image Download
<?php 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; use RuntimeException; use TypeError; use function array_keys; use function file_exists; use function file_get_contents; use function file_put_contents; use function filter_var; use function hash; use function header; use function headers_sent; use function implode; use function in_array; use function is_array; use function is_string; use function json_encode; use function json_decode; use function preg_replace; use function random_bytes; use function rtrim; use function str_replace; use function strpos; /** * Class CSPBuilder * @package ParagonIE\CSPBuilder */ class CSPBuilder { const FORMAT_APACHE = 'apache'; const FORMAT_NGINX = 'nginx'; /** * @var array<array-key, mixed> */ private $policies = []; /** * @var array<int, string> */ private $requireSRIFor = []; /** * @var bool */ private $needsCompile = true; /** * @var string */ private $compiled = ''; /** * @var array */ private $reportEndpoints = []; /** * @var string */ private $compiledEndpoints = ''; /** * @var bool */ private $needsCompileEndpoints = true; /** * @var bool */ private $reportOnly = false; /** * @var bool */ protected $supportOldBrowsers = true; /** * @var bool */ protected $httpsTransformOnHttpsConnections = true; /** * @var string[] */ private static $directives = [ 'base-uri', 'default-src', 'child-src', 'connect-src', 'font-src', 'form-action', 'frame-ancestors', 'frame-src', 'img-src', 'media-src', 'object-src', 'plugin-types', 'manifest-src', 'sandbox', 'script-src', 'script-src-elem', 'script-src-attr', 'style-src', 'style-src-elem', 'style-src-attr', 'worker-src' ]; /** * @param array $policy */ public function __construct(array $policy = []) { $this->policies = $policy; } /** * Compile the current policies into a CSP header * * @return string * @throws TypeError */ public function compile(): string { $ruleKeys = array_keys($this->policies); if (in_array('report-only', $ruleKeys)) { $this->reportOnly = !!$this->policies['report-only']; } else { $this->reportOnly = false; } $compiled = []; foreach (self::$directives as $dir) { if (in_array($dir, $ruleKeys)) { if (empty($ruleKeys)) { if ($dir === 'base-uri') { continue; } } $compiled []= $this->compileSubgroup( $dir, $this->policies[$dir] ); } } if (!empty($this->policies['report-uri'])) { if (!is_string($this->policies['report-uri'])) { throw new TypeError('report-uri policy somehow not a string'); } $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'); } // @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'; } $this->compiled = rtrim(implode('', $compiled), '; '); $this->needsCompile = false; return $this->compiled; } /** * @psalm-suppress DocblockTypeContradiction * @psalm-suppress TypeDoesNotContainType */ public function compileReportEndpoints(): string { 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'); } $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; } return $this->compiledEndpoints; } /** * Add a source to our allow white-list * * @param string $directive * @param string $path * * @return self */ public function addSource(string $directive, string $path): self { $this->needsCompile = true; switch ($directive) { case 'child': case 'child-src': if ($this->supportOldBrowsers) { $this->policies['child-src']['allow'][] = $path; $this->policies['frame-src']['allow'][] = $path; return $this; } $directive = 'child-src'; break; case 'frame': case 'frame-src': if ($this->supportOldBrowsers) { $this->policies['child-src']['allow'][] = $path; $this->policies['frame-src']['allow'][] = $path; return $this; } $directive = 'frame-src'; break; case 'connect': case 'socket': case 'websocket': $directive = 'connect-src'; break; case 'font': case 'fonts': $directive = 'font-src'; break; case 'form': case 'forms': $directive = 'form-action'; break; case 'ancestor': case 'parent': $directive = 'frame-ancestors'; break; case 'img': case 'image': case 'image-src': $directive = 'img-src'; break; case 'media': $directive = 'media-src'; break; case 'object': $directive = 'object-src'; break; case 'js': case 'javascript': case 'script': case 'scripts': $directive = 'script-src'; break; case 'style': case 'css': case 'css-src': $directive = 'style-src'; break; case 'worker': $directive = 'worker-src'; break; } if (!isset($this->policies[$directive])) { $this->policies[$directive] = []; } if (!isset($this->policies[$directive]['allow'])) { $this->policies[$directive]['allow'] = []; } if (is_array($this->policies[$directive]['allow'])) { if (!in_array($path, $this->policies[$directive]['allow'], true)) { $this->policies[$directive]['allow'][] = $path; } } return $this; } /** * Add a directive if it doesn't already exist * * If it already exists, do nothing * * @param string $key * @param mixed $value * * @return self */ public function addDirective(string $key, $value = null): self { $this->needsCompile = true; if ($value === null) { if (!isset($this->policies[$key])) { $this->policies[$key] = true; } } elseif (empty($this->policies[$key])) { $this->policies[$key] = $value; } return $this; } /** * @param array|string $reportEndpoint * @return void */ public function addReportEndpoints($reportEndpoint): void { $this->needsCompileEndpoints = true; $this->reportEndpoints[] = Helper::toJSON($reportEndpoint); } /** * Add a plugin type to be added * * @param string $mime * @return self */ public function allowPluginType(string $mime = 'text/plain'): self { $this->policies['plugin-types']['types'] []= $mime; $this->needsCompile = true; return $this; } /** * Disable old browser support (e.g. Safari) * * @return self */ public function disableOldBrowserSupport(): self { $this->needsCompile = ($this->needsCompile || $this->supportOldBrowsers !== false); $this->supportOldBrowsers = false; return $this; } /** * Enable old browser support (e.g. Safari) * * This is enabled by default * * @return self */ public function enableOldBrowserSupport(): self { $this->needsCompile = ($this->needsCompile || $this->supportOldBrowsers !== true); $this->supportOldBrowsers = true; return $this; } /** * This just passes the array to the constructor, but hopefully will save * someone in a hurry from a moment of frustration. * * @param array $array * @return self */ public static function fromArray(array $array = []): self { return new CSPBuilder($array); } /** * Factory method - create a new CSPBuilder object from JSON data * * @param string $data * @return self * @throws Exception */ public static function fromData(string $data = ''): self { $array = json_decode($data, true); if (!is_array($array)) { throw new Exception('Is not array valid'); } return new CSPBuilder($array); } /** * Factory method - create a new CSPBuilder object from a JSON file * * @param string $filename * @return self * @throws Exception */ public static function fromFile(string $filename = ''): self { if (!file_exists($filename)) { throw new Exception($filename.' does not exist'); } $contents = file_get_contents($filename); if (!is_string($contents)) { throw new Exception('Could not read file contents'); } return self::fromData($contents); } /** * Factory method - create a new CSPBuilder object from an existing CSP header * * @param string $header * @return self * @throws Exception * * @psalm-suppress DocblockTypeContradiction */ public static function fromHeader(string $header = ''): self { $csp = new CSPBuilder(); $directives = explode(';', $header); foreach ($directives as $directive) { [$name, $values] = explode(' ', trim($directive), 2) + [null, null]; if (is_null($name)) { continue; } if ('upgrade-insecure-requests' === $name) { $csp->addDirective('upgrade-insecure-requests'); continue; } if (null === $values) { continue; } foreach (explode(' ', $values) as $value) { if ('report-to' === $name) { $csp->setReportTo($value); } elseif ('report-uri' === $name) { $csp->setReportUri($value); } elseif ('require-sri-for' === $name) { $csp->requireSRIFor($value); } elseif ('plugin-types' === $name) { $csp->allowPluginType($value); } else { switch ($value) { case "'none'": $csp->addDirective($name, false); break; case "'self'": $csp->setSelfAllowed($name, true); break; case 'blob:': $csp->setBlobAllowed($name, true); break; case 'data:': $csp->setDataAllowed($name, true); break; case 'filesystem:': $csp->setFileSystemAllowed($name, true); break; case 'https:': $csp->setHttpsAllowed($name, true); break; case 'mediastream:': $csp->setMediaStreamAllowed($name, true); break; case "'report-sample'": $csp->setReportSample($name, true); break; case "'strict-dynamic'": $csp->setStrictDynamic($name, true); break; case "'unsafe-eval'": $csp->setAllowUnsafeEval($name, true); break; case "'unsafe-hashes'": $csp->setAllowUnsafeHashes($name, true); break; case "'unsafe-inline'": $csp->setAllowUnsafeInline($name, true); break; case "'unsafe-hashed-attributes'": $csp->setAllowUnsafeHashedAttributes('script-src', true); break; default: $csp->addSource($name, $value); } } } } return $csp; } /** * Get the formatted CSP header * * @return string */ public function getCompiledHeader(): string { if ($this->needsCompile) { $this->compile(); } 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. * * @param bool $legacy * @return array<string, string> */ public function getHeaderArray(bool $legacy = true): array { $return = []; if ($this->needsCompile) { $this->compile(); } 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; } return $return; } /** * @return array<int, array{0:string, 1:string}> */ public function getRequireHeaders(): array { $headers = []; foreach ($this->requireSRIFor as $directive) { $headers[] = [ 'Content-Security-Policy', 'require-sri-for ' . $directive ]; } return $headers; } /** * @return array */ public function getReportEndpoints(): array { return $this->reportEndpoints; } /** * Add a new hash to the existing CSP * * @param string $directive * @param string $script * @param string $algorithm * @return self */ public function hash( string $directive = 'script-src', string $script = '', string $algorithm = 'sha384' ): self { $ruleKeys = array_keys($this->policies); if (in_array($directive, $ruleKeys)) { $this->policies[$directive]['hashes'] []= [ $algorithm => Base64::encode( hash($algorithm, $script, true) ) ]; } return $this; } /** * PSR-7 header injection. * * This will inject the header into your PSR-7 object. (Request, Response, * etc.) This method returns an instance of whatever you passed, so long * as it implements MessageInterface. * * @param MessageInterface $message * @param bool $legacy * @return MessageInterface */ public function injectCSPHeader(MessageInterface $message, bool $legacy = false): MessageInterface { if ($this->needsCompile) { $this->compile(); } if ($this->needsCompileEndpoints) { $this->compileReportEndpoints(); } foreach ($this->getRequireHeaders() as $header) { list ($key, $value) = $header; $message = $message->withAddedHeader($key, $value); } foreach ($this->getHeaderKeys($legacy) as $key) { $message = $message->withAddedHeader($key, $this->compiled); } if (!empty($this->compileReportEndpoints())) { $message = $message->withAddedHeader('report-to', $this->compiledEndpoints); } return $message; } /** * Add a new nonce to the existing CSP. Returns the nonce generated. * * @param string $directive * @param string $nonce (if empty, it will be generated) * @return string * @throws Exception */ public function nonce(string $directive = 'script-src', string $nonce = ''): string { $ruleKeys = array_keys($this->policies); if (!in_array($directive, $ruleKeys) && !in_array('default-src', $ruleKeys)) { return ''; } if (empty($nonce)) { $nonce = Base64::encode(random_bytes(18)); } $this->policies[$directive]['nonces'] []= $nonce; return $nonce; } /** * Add a new (pre-calculated) base64-encoded hash to the existing CSP * * @param string $directive * @param string $hash * @param string $algorithm * @return self */ public function preHash( string $directive = 'script-src', string $hash = '', string $algorithm = 'sha384' ): self { $ruleKeys = array_keys($this->policies); if (in_array($directive, $ruleKeys)) { $this->policies[$directive]['hashes'] []= [ $algorithm => $hash ]; } return $this; } /** * @param string $directive * @return self */ public function requireSRIFor(string $directive): self { if (!in_array($directive, $this->requireSRIFor, true)) { $this->requireSRIFor[] = $directive; } return $this; } /** * Save CSP to a snippet file * * @param string $outputFile Output file name * @param string $format Which format are we saving in? * @return bool * @throws Exception */ public function saveSnippet( string $outputFile, string $format = self::FORMAT_NGINX, ?callable $hookBeforeSave = null ): bool { if ($this->needsCompile) { $this->compile(); $this->compileReportEndpoints(); } // Are we doing a report-only header? $which = $this->reportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; switch ($format) { case self::FORMAT_NGINX: // In PHP < 7, implode() is faster than concatenation $output = implode('', [ 'add_header ', $which, ' "', rtrim($this->compiled, ' '), '" always;', "\n" ]); break; case self::FORMAT_APACHE: $output = implode('', [ 'Header add ', $which, ' "', rtrim($this->compiled, ' '), '"', "\n" ]); break; default: throw new Exception('Unknown format: '.$format); } if ($hookBeforeSave !== null) { $output = $hookBeforeSave($output); } return file_put_contents($outputFile, $output) !== false; } /** * Send the compiled CSP as a header() * * @param bool $legacy Send legacy headers? * * @return bool * @throws Exception */ public function sendCSPHeader(bool $legacy = true): bool { if (headers_sent()) { throw new Exception('Headers already sent!'); } if ($this->needsCompile) { $this->compile(); } if ($this->needsCompileEndpoints) { $this->compileReportEndpoints(); } foreach ($this->getRequireHeaders() as $header) { list ($key, $value) = $header; header(sprintf('%s: %s', $key, $value)); } foreach ($this->getHeaderKeys($legacy) as $key) { header(sprintf('%s: %s', $key, $this->compiled)); } if (!empty($this->compiledEndpoints)) { header(sprintf('report-to: %s', $this->compiledEndpoints)); } return true; } /** * Allow/disallow unsafe-eval within a given directive. * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setAllowUnsafeEval(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['unsafe-eval'] = $allow; return $this; } /** * Allow/disallow unsafe-inline within a given directive. * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setAllowUnsafeInline(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['unsafe-inline'] = $allow; return $this; } /** * Allow/disallow unsafe-hashed-attributes within a given directive. * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setAllowUnsafeHashedAttributes(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['unsafe-hashed-attributes'] = $allow; return $this; } /** * Allow/disallow blob: URIs for a given directive * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setBlobAllowed(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['blob'] = $allow; return $this; } /** * Allow/disallow data: URIs for a given directive * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setDataAllowed(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['data'] = $allow; return $this; } /** * Set a directive. * * This lets you overwrite a complex directive entirely (e.g. script-src) * or set a top-level directive (e.g. report-uri). * * @param string $key * @param mixed $value * * @return self */ public function setDirective(string $key, $value = []): self { $this->policies[$key] = $value; return $this; } /** * Removes a directive. * * This allows removing a directive if the presence of it might cause * undesired behavioral changes. * * @param string $key * * @return self */ public function removeDirective(string $key): self { unset($this->policies[$key]); return $this; } /** * @param array|string $reportEndpoints * @return void */ public function setReportEndpoints($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; } /** * @param string $key * @return void */ public function removeReportEndpoint(string $key): void { 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 * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setFileSystemAllowed(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['filesystem'] = $allow; return $this; } /** * Allow/disallow mediastream: URIs for a given directive * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setMediaStreamAllowed(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['mediastream'] = $allow; return $this; } /** * Allow/disallow loading resources only over HTTPS on any domain for a given directive * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setHttpsAllowed(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['https'] = $allow; return $this; } /** * Allow/disallow self URIs for a given directive * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setSelfAllowed(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['self'] = $allow; return $this; } /** * @see CSPBuilder::setAllowUnsafeEval() * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setUnsafeEvalAllowed(string $directive = '', bool $allow = false): self { return $this->setAllowUnsafeEval($directive, $allow); } /** * Allow/disallow unsafe-hashes within a given directive. * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setAllowUnsafeHashes(string $directive = '', bool $allow = false): self { if (!in_array($directive, self::$directives)) { throw new Exception('Directive ' . $directive . ' does not exist'); } $this->policies[$directive]['unsafe-hashes'] = $allow; return $this; } /** * @see CSPBuilder::setAllowUnsafeInline() * * @param string $directive * @param bool $allow * @return self * @throws Exception */ public function setUnsafeInlineAllowed(string $directive = '', bool $allow = false): self { return $this->setAllowUnsafeInline($directive, $allow); } /** * Set strict-dynamic for a given directive. * * @param string $directive * @param bool $allow * * @return self * @throws Exception */ public function setStrictDynamic(string $directive = '', bool $allow = false): self { $this->policies[$directive]['strict-dynamic'] = $allow; return $this; } /** * Set report-sample for a given directive. * * @param string $directive * @param bool $allow * * @return self * @throws Exception */ public function setReportSample(string $directive = '', bool $allow = false): self { $this->policies[$directive]['report-sample'] = $allow; return $this; } /** * Set the Report URI to the desired string. This also sets the 'report-to' * component of the CSP header for CSP Level 3 compatibility. * * @param string $url * @return self */ public function setReportUri(string $url = ''): self { $this->policies['report-uri'] = $url; return $this; } /** * Set the report-to directive to the desired string. * * @param string|array $policy * @return self */ public function setReportTo($policy = ''): self { $this->policies['report-to'] = $policy; return $this; } /** * Compile a subgroup into a policy string * * @param string $directive * @param mixed $policies * * @return string */ protected function compileSubgroup(string $directive, $policies = []): string { if ($policies === '*') { // Don't even waste the overhead adding this to the header return ''; } elseif (empty($policies)) { if ($directive === 'plugin-types') { return ''; } elseif ($directive === 'sandbox') { return $this->enc($directive) . '; '; } return $directive." 'none'; "; } /** @var array<array-key, mixed> $policies */ $ret = $this->enc($directive) . ' '; if ($directive === 'plugin-types') { // Expects MIME types, not URLs $types = trim($this->enc(implode(' ', $policies['types']), 'mime')); return $types ? $ret . $types . '; ' : ''; } if (!empty($policies['self'])) { $ret .= "'self' "; } if (!empty($policies['allow'])) { /** @var array<array-key, string> $allowedPolicies */ $allowedPolicies = $policies['allow']; foreach (array_unique($allowedPolicies) as $url) { /** @var string|bool $url */ $url = filter_var($url, FILTER_SANITIZE_URL); if (is_string($url)) { if ($this->supportOldBrowsers && $directive !== 'sandbox') { if (strpos($url, '://') === false) { if (($this->isHTTPSConnection() && $this->httpsTransformOnHttpsConnections) || !empty($this->policies['upgrade-insecure-requests'])) { // We only want HTTPS connections here. $ret .= 'https://'.$url.' '; } else { $ret .= 'https://'.$url.' http://'.$url.' '; } } } if (($this->isHTTPSConnection() && $this->httpsTransformOnHttpsConnections) || !empty($this->policies['upgrade-insecure-requests'])) { $ret .= str_replace('http://', 'https://', $url).' '; } else { $ret .= $url.' '; } } } } if (!empty($policies['hashes'])) { /** @var array<array-key, array<string, string>> $hashes */ $hashes = $policies['hashes']; foreach ($hashes as $hash) { /** * @var string $algo * @var string $hashval */ foreach ($hash as $algo => $hashval) { $ret .= implode('', [ "'", preg_replace('/[^A-Za-z0-9]/', '', $algo), '-', preg_replace('/[^A-Za-z0-9\+\/=]/', '', $hashval), "' " ]); } } } if (!empty($policies['nonces'])) { /** @var array<array-key, string> $nonces */ $nonces = $policies['nonces']; foreach ($nonces as $nonce) { $ret .= implode('', [ "'nonce-", preg_replace('/[^A-Za-z0-9\+\/=]/', '', $nonce), "' " ]); } } if (!empty($policies['types'])) { /** @var array<array-key, string> $types */ $types = $policies['types']; foreach ($types as $type) { $ret .= $type . ' '; } } if (!empty($policies['unsafe-hashes'])) { $ret .= "'unsafe-hashes' "; } if (!empty($policies['unsafe-inline'])) { $ret .= "'unsafe-inline' "; } if (!empty($policies['unsafe-eval'])) { $ret .= "'unsafe-eval' "; } if (!empty($policies['blob'])) { $ret .= "blob: "; } if (!empty($policies['data'])) { $ret .= "data: "; } if (!empty($policies['mediastream'])) { $ret .= "mediastream: "; } if (!empty($policies['filesystem'])) { $ret .= "filesystem: "; } if (!empty($policies['https'])) { $ret .= "https: "; } if (!empty($policies['strict-dynamic'])) { $ret .= "'strict-dynamic' "; } if (!empty($policies['report-sample'])) { $ret .= "'report-sample' "; } if (!empty($policies['unsafe-hashed-attributes'])) { $ret .= "'unsafe-hashed-attributes' "; } return rtrim($ret, ' ').'; '; } /** * Get an array of header keys to return * * @param bool $legacy * @return array */ protected function getHeaderKeys(bool $legacy = true): array { // We always want this $return = [ $this->reportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy' ]; // If we're supporting legacy devices, include these too: if ($legacy) { $return []= $this->reportOnly ? 'X-Content-Security-Policy-Report-Only' : 'X-Content-Security-Policy'; $return []= $this->reportOnly ? 'X-Webkit-CSP-Report-Only' : 'X-Webkit-CSP'; } return $return; } /** * @param string $piece * @param string $type * @return string */ protected function enc(string $piece, string $type = 'default'): string { switch ($type) { case 'report-uri': return str_replace(["\r", "\n", ';'], '', $piece); case 'mime': if (preg_match('#^([a-z0-9\-/]+)#', $piece, $matches)) { return $matches[1]; } return ''; case 'url': return urlencode($piece); default: // Don't inject return str_replace( [';', "\r", "\n", ':'], ['%3B', '%0D', '%0A', '%3A'], $piece ); } } /** * Is this user currently connected over HTTPS? * * @return bool * @psalm-suppress RiskyTruthyFalsyComparison */ protected function isHTTPSConnection(): bool { if (!empty($_SERVER['HTTPS'])) { return $_SERVER['HTTPS'] !== 'off'; } return false; } /** * Disable that HTTP sources get converted to HTTPS if the connection is such. * * @return self */ public function disableHttpsTransformOnHttpsConnections(): self { $this->needsCompile = ($this->needsCompile || $this->httpsTransformOnHttpsConnections !== false); $this->httpsTransformOnHttpsConnections = false; return $this; } /** * Enable that HTTP sources get converted to HTTPS if the connection is such. * * This is enabled by default * * @return self */ public function enableHttpsTransformOnHttpsConnections(): self { $this->needsCompile = ($this->needsCompile || $this->httpsTransformOnHttpsConnections !== true); $this->httpsTransformOnHttpsConnections = true; return $this; } /** * Export the policies object as a JSON string * * @return string */ public function exportPolicies(): string { return json_encode($this->policies, JSON_PRETTY_PRINT); } /** * Save the configured policies to a JSON file. * * @param string $filePath * @return bool */ public function saveToFile(string $filePath): bool { if (!is_writable($filePath)) { throw new RuntimeException('Cannot write to ' . $filePath); } return file_put_contents( $filePath, $this->exportPolicies() ) !== false; } }