<?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;
}
}
|