View source
<?php
namespace Drupal\csp;
class Csp {
const HASH_ALGORITHMS = [
'sha256',
'sha384',
'sha512',
];
const POLICY_ANY = "*";
const POLICY_NONE = "'none'";
const POLICY_SELF = "'self'";
const POLICY_STRICT_DYNAMIC = "'strict-dynamic'";
const POLICY_UNSAFE_EVAL = "'unsafe-eval'";
const POLICY_UNSAFE_INLINE = "'unsafe-inline'";
const POLICY_UNSAFE_HASHES = "'unsafe-hashes'";
const DIRECTIVE_SCHEMA_SOURCE_LIST = 'serialized-source-list';
const DIRECTIVE_SCHEMA_ANCESTOR_SOURCE_LIST = 'ancestor-source-list';
const DIRECTIVE_SCHEMA_MEDIA_TYPE_LIST = 'media-type-list';
const DIRECTIVE_SCHEMA_TOKEN_LIST = 'token-list';
const DIRECTIVE_SCHEMA_OPTIONAL_TOKEN_LIST = 'optional-token-list';
const DIRECTIVE_SCHEMA_TOKEN = 'token';
const DIRECTIVE_SCHEMA_URI_REFERENCE_LIST = 'uri-reference-list';
const DIRECTIVE_SCHEMA_BOOLEAN = 'boolean';
const DIRECTIVES = [
'default-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'child-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'connect-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'font-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'frame-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'img-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'manifest-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'media-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'object-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'prefetch-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'script-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'script-src-attr' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'script-src-elem' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'style-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'style-src-attr' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'style-src-elem' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'worker-src' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'base-uri' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'plugin-types' => self::DIRECTIVE_SCHEMA_MEDIA_TYPE_LIST,
'sandbox' => self::DIRECTIVE_SCHEMA_OPTIONAL_TOKEN_LIST,
'form-action' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'frame-ancestors' => self::DIRECTIVE_SCHEMA_ANCESTOR_SOURCE_LIST,
'navigate-to' => self::DIRECTIVE_SCHEMA_SOURCE_LIST,
'report-uri' => self::DIRECTIVE_SCHEMA_URI_REFERENCE_LIST,
'report-to' => self::DIRECTIVE_SCHEMA_TOKEN,
'block-all-mixed-content' => self::DIRECTIVE_SCHEMA_BOOLEAN,
'upgrade-insecure-requests' => self::DIRECTIVE_SCHEMA_BOOLEAN,
'referrer' => self::DIRECTIVE_SCHEMA_TOKEN,
'require-sri-for' => self::DIRECTIVE_SCHEMA_TOKEN_LIST,
];
const DIRECTIVES_FALLBACK = [
'script-src-elem' => [
'script-src',
'default-src',
],
'script-src-attr' => [
'script-src',
'default-src',
],
'script-src' => [
'default-src',
],
'style-src-elem' => [
'style-src',
'default-src',
],
'style-src-attr' => [
'style-src',
'default-src',
],
'style-src' => [
'default-src',
],
'worker-src' => [
'child-src',
'script-src',
'default-src',
],
'child-src' => [
'script-src',
'default-src',
],
'connect-src' => [
'default-src',
],
'manifest-src' => [
'default-src',
],
'prefetch-src' => [
'default-src',
],
'object-src' => [
'default-src',
],
'frame-src' => [
'child-src',
'default-src',
],
'media-src' => [
'default-src',
],
'font-src' => [
'default-src',
],
'img-src' => [
'default-src',
],
];
protected $reportOnly = FALSE;
protected $directives = [];
public static function calculateHash($data, $algorithm = 'sha256') {
if (!in_array($algorithm, self::HASH_ALGORITHMS)) {
throw new \InvalidArgumentException("Specified hash algorithm is not supported");
}
return $algorithm . '-' . base64_encode(hash($algorithm, $data, TRUE));
}
public static function isValidDirectiveName($name) {
return array_key_exists($name, static::DIRECTIVES);
}
private static function validateDirectiveName($name) {
if (!static::isValidDirectiveName($name)) {
throw new \InvalidArgumentException("Invalid directive name provided");
}
}
public static function getDirectiveNames() {
return array_keys(self::DIRECTIVES);
}
public static function getDirectiveSchema($name) {
self::validateDirectiveName($name);
return self::DIRECTIVES[$name];
}
public static function getDirectiveFallbackList($name) {
self::validateDirectiveName($name);
if (array_key_exists($name, self::DIRECTIVES_FALLBACK)) {
return self::DIRECTIVES_FALLBACK[$name];
}
return [];
}
public function reportOnly($value = TRUE) {
$this->reportOnly = $value;
}
public function isReportOnly() {
return $this->reportOnly;
}
public function hasDirective($name) {
return isset($this->directives[$name]);
}
public function getDirective($name) {
self::validateDirectiveName($name);
return $this->directives[$name];
}
public function setDirective($name, $value) {
self::validateDirectiveName($name);
if (self::DIRECTIVES[$name] === self::DIRECTIVE_SCHEMA_BOOLEAN) {
$this->directives[$name] = (bool) $value;
return;
}
$this->directives[$name] = [];
if (empty($value)) {
return;
}
$this
->appendDirective($name, $value);
}
public function appendDirective($name, $value) {
self::validateDirectiveName($name);
if (empty($value)) {
return;
}
if (gettype($value) === 'string') {
$value = explode(' ', $value);
}
elseif (gettype($value) !== 'array') {
throw new \InvalidArgumentException("Invalid directive value provided");
}
if (!isset($this->directives[$name])) {
$this->directives[$name] = [];
}
$this->directives[$name] = array_merge($this->directives[$name], $value);
}
public function fallbackAwareAppendIfEnabled($name, $value) {
self::validateDirectiveName($name);
if (!$this
->hasDirective($name)) {
foreach (self::getDirectiveFallbackList($name) as $fallback) {
if ($this
->hasDirective($fallback)) {
$fallbackSourceList = $this
->getDirective($fallback);
if (in_array(static::POLICY_NONE, $fallbackSourceList)) {
$fallbackSourceList = [];
}
$this
->setDirective($name, $fallbackSourceList);
break;
}
}
}
if ($this
->hasDirective($name)) {
$this
->appendDirective($name, $value);
}
}
public function removeDirective($name) {
self::validateDirectiveName($name);
unset($this->directives[$name]);
}
public function getHeaderName() {
return 'Content-Security-Policy' . ($this->reportOnly ? '-Report-Only' : '');
}
public function getHeaderValue() {
$output = [];
$optimizedDirectives = [];
foreach ($this->directives as $name => $value) {
if (empty($value) && self::DIRECTIVES[$name] !== self::DIRECTIVE_SCHEMA_OPTIONAL_TOKEN_LIST) {
continue;
}
if (self::DIRECTIVES[$name] === self::DIRECTIVE_SCHEMA_BOOLEAN || self::DIRECTIVES[$name] === self::DIRECTIVE_SCHEMA_OPTIONAL_TOKEN_LIST && empty($value)) {
$output[] = $name;
continue;
}
if (in_array(self::DIRECTIVES[$name], [
self::DIRECTIVE_SCHEMA_SOURCE_LIST,
self::DIRECTIVE_SCHEMA_ANCESTOR_SOURCE_LIST,
])) {
$value = self::reduceSourceList($value);
}
$optimizedDirectives[$name] = $value;
}
foreach ($optimizedDirectives as $name => $value) {
foreach (self::getDirectiveFallbackList($name) as $fallbackDirective) {
if (isset($optimizedDirectives[$fallbackDirective])) {
if ($optimizedDirectives[$fallbackDirective] === $value) {
unset($optimizedDirectives[$name]);
continue 2;
}
else {
break;
}
}
}
if (strstr($name, '-attr')) {
$optimizedDirectives[$name] = self::reduceAttrSourceList($value);
}
}
$optimizedDirectives = self::ff1313937($optimizedDirectives);
$optimizedDirectives = self::sortDirectives($optimizedDirectives);
foreach ($optimizedDirectives as $name => $value) {
$output[] = $name . ' ' . implode(' ', $value);
}
return implode('; ', $output);
}
private static function reduceSourceList(array $sources) {
$sources = array_unique($sources);
if (in_array(Csp::POLICY_NONE, $sources)) {
return [
Csp::POLICY_NONE,
];
}
if (in_array(Csp::POLICY_ANY, $sources)) {
$sources = array_filter($sources, function ($source) {
return strpos($source, "'") === 0 || preg_match('<^(?!(?:https?|wss?|ftp):)([a-z]+:)>', $source);
});
array_unshift($sources, Csp::POLICY_ANY);
}
$protocols = array_filter($sources, function ($source) {
return preg_match('<^(https?|wss?|ftp):$>', $source);
});
if (!empty($protocols)) {
if (in_array('http:', $protocols)) {
$protocols[] = 'https:';
}
if (in_array('ws:', $protocols)) {
$protocols[] = 'wss:';
}
$sources = array_filter($sources, function ($source) use ($protocols) {
return !preg_match('<^(' . implode('|', $protocols) . ')//>', $source);
});
}
return $sources;
}
private static function reduceAttrSourceList(array $sources) {
$sources = array_filter($sources, function ($source) {
return $source[0] === "'" && $source !== "*" && strpos($source, "'nonce-") !== 0;
});
if (!in_array(self::POLICY_UNSAFE_HASHES, $sources)) {
$sources = array_filter($sources, function ($source) {
return !preg_match("<'(" . implode('|', self::HASH_ALGORITHMS) . ")-[a-z0-9+/=]+=*'>i", $source);
});
}
if (empty($sources)) {
$sources = [
self::POLICY_NONE,
];
}
return $sources;
}
public static function sortDirectives(array $directives) {
$order = array_flip(array_keys(self::DIRECTIVES));
uksort($directives, function ($a, $b) use ($order) {
return $order[$a] <=> $order[$b];
});
return $directives;
}
private static function ff1313937(array $directives) {
if (empty($directives['default-src'])) {
return $directives;
}
$hasBugSource = array_reduce($directives['default-src'], function ($return, $value) {
return $return || ($value == Csp::POLICY_STRICT_DYNAMIC || preg_match("<^'(hash|nonce)->", $value));
}, FALSE);
if ($hasBugSource) {
if (empty($directives['script-src'])) {
$directives['script-src'] = $directives['default-src'];
}
if (empty($directives['style-src'])) {
$directives['style-src'] = array_diff($directives['default-src'], [
Csp::POLICY_STRICT_DYNAMIC,
]);
}
}
return $directives;
}
public function __toString() {
return $this
->getHeaderName() . ': ' . $this
->getHeaderValue();
}
}