src/Extensions/ControllerCSPExtension.php
<?php
namespace Firesphere\CSPHeaders\Extensions;
use Exception;
use Firesphere\CSPHeaders\Models\CSPDomain;
use Firesphere\CSPHeaders\View\CSPBackend;
use LeKoala\DebugBar\DebugBar;
use ParagonIE\ConstantTime\Base64;
use ParagonIE\CSPBuilder\CSPBuilder;
use phpDocumentor\Reflection\Types\Boolean;
use SilverStripe\Admin\LeftAndMain;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Cookie;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Environment;
use SilverStripe\Core\Extension;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DB;
use function hash;
/**
* Class \Firesphere\CSPHeaders\Extensions\ControllerCSPExtension
*
* This extension is applied to the PageController, to avoid duplicates.
* Any duplicates may be caused by extended classes. It should however, not affect the outcome
*
* @property Controller|ControllerCSPExtension $owner
*/
class ControllerCSPExtension extends Extension
{
public static $isTesting = false;
/**
* Base CSP configuration
* @var array
*/
protected static $csp_config;
/**
* @var array
*/
protected static $inlineJS = [];
/**
* @var array
*/
protected static $inlineCSS = [];
/**
* Should we generate the policy headers or not
* @var bool
*/
protected $addPolicyHeaders;
/**
* Should permission policies be added
* @var bool
*/
protected $addPermissionHeaders;
/**
* @var string randomised sha512 nonce for enabling scripts if you don't want to use validating of the full script
*/
protected $nonce;
/**
* @var array
*/
protected $headTags = [];
/**
* @param string $js
*/
public static function addJS($js)
{
static::$inlineJS[] = $js;
}
/**
* @param string $css
*/
public static function addCSS($css)
{
static::$inlineCSS[] = $css;
}
/**
* @return array
*/
public static function getInlineJS()
{
return static::$inlineJS;
}
/**
* @return array
*/
public static function getInlineCSS()
{
return static::$inlineCSS;
}
/**
* Add the needed headers from the database and config
* @throws Exception
*/
public function onBeforeInit()
{
if (self::$isTesting || !DB::is_active() || !ClassInfo::hasTable('Member') || Director::is_cli()) {
return;
}
$config = CSPBackend::config();
/** @var Controller $owner */
$owner = $this->owner;
$cspConfig = $config->get('csp_config');
if ($this->owner instanceof LeftAndMain && $cspConfig['in_cms'] === false) {
return;
}
$permissionConfig = $config->get('permissions_config');
$this->addPolicyHeaders = ($cspConfig['enabled'] ?? false) || static::checkCookie($owner->getRequest());
$this->addPermissionHeaders = $permissionConfig['enabled'] ?? false;
/** @var Controller $owner */
// Policy-headers
if ($this->addPolicyHeaders) {
$this->addCSPHeaders($cspConfig, $owner);
}
// Permission-policy
if ($this->addPermissionHeaders) {
$this->addPermissionsHeaders($permissionConfig, $owner);
}
// Referrer-Policy
if ($referrerPolicy = $config->get('referrer')) {
$this->addResponseHeaders(['Referrer-Policy' => $referrerPolicy], $owner);
}
// X-Frame-Options
if ($frameOptions = $config->get('frame-options')) {
$this->addResponseHeaders(['X-Frame-Options' => $frameOptions], $owner);
}
// X-Content-Type-Options
if ($ContentTypeOptions = $config->get('content-type-options')) {
$this->addResponseHeaders(['X-Content-Type-Options' => $ContentTypeOptions], $owner);
}
// Strict-Transport-Security
$hsts = $config->get('HSTS');
if ($hsts && $hsts['enabled']) {
$header = $hsts['max-age'] ? sprintf('max-age=%s; ', $hsts['max-age']) : '0';
$header .= $hsts['include_subdomains'] ? 'includeSubDomains' : '';
$this->addResponseHeaders(['Strict-Transport-Security' => trim($header)], $owner);
}
// Access-Control-Allow-Origin
$cors = $config->get('CORS');
if ($cors && $cors['enabled']) {
$responseHeaders = ['Access-Control-Allow-Methods' => implode(',', $cors['methods'])];
if (in_array('*', $cors['allow'])) {
$responseHeaders['Access-Control-Allow-Origin'] = '*';
} else {
$origin = (string)$owner->getRequest()->getHeader('origin');
$base = Director::absoluteBaseURL();
$trimmedOrigin = $this->trimDomain($origin);
$trimmedBase = $this->trimDomain($base);
$domains = explode(',', (string)Environment::getEnv('SS_ALLOWED_HOSTS'));
$domains = array_merge($domains, $cors['allow'], [$trimmedBase]);
if (in_array($trimmedOrigin, $domains)) {
$allow = strlen($trimmedOrigin) ? $origin : $base;
$responseHeaders['Access-Control-Allow-Origin'] = $allow;
}
}
$this->addResponseHeaders($responseHeaders, $owner);
}
}
/**
* @param HTTPRequest $request
* @return bool
*/
public static function checkCookie($request): bool
{
if ($request->getVar('build-headers')) {
Cookie::set('buildHeaders', $request->getVar('build-headers'));
}
return (Cookie::get('buildHeaders') === 'true');
}
/**
* @param mixed $ymlConfig
* @param Controller $owner
* @return void
* @throws Exception
*/
private function addCSPHeaders(mixed $ymlConfig, Controller $owner): void
{
$config = Injector::inst()->convertServiceProperty($ymlConfig);
$legacy = $config['legacy'] ?? true;
$unsafeCSSInline = $config['style-src']['unsafe-inline'];
$unsafeJsInline = $config['script-src']['unsafe-inline'];
if (class_exists('\Page') && $owner && $owner->dataRecord) {
$config['style-src']['unsafe-inline'] = $unsafeCSSInline || $owner->dataRecord->AllowCSSInline;
$config['script-src']['unsafe-inline'] = $unsafeJsInline || $owner->dataRecord->AllowJSInline;
}
$policy = CSPBuilder::fromArray($config);
$this->addCSP($policy, $owner);
$this->addInlineJSPolicy($policy, $config);
$this->addInlineCSSPolicy($policy, $config);
// When in dev, add the debugbar nonce, requires a change to the lib
if (Director::isDev() && class_exists('LeKoala\DebugBar\DebugBar')) {
$bar = DebugBar::getDebugBar();
if ($bar) {
$bar->getJavascriptRenderer()->setCspNonce('debugbar');
$policy->nonce('script-src', 'debugbar');
}
}
$headers = $policy->getHeaderArray($legacy);
$this->addResponseHeaders($headers, $owner);
}
/**
* @param CSPBuilder $policy
* @param Controller $owner
*/
protected function addCSP($policy, $owner): void
{
/** @var DataList|CSPDomain[] $cspDomains */
$cspDomains = CSPDomain::get();
if (class_exists('\Page')) {
$cspDomains = $cspDomains->filterAny(['Pages.ID' => [null, $owner->ID]]);
}
foreach ($cspDomains as $domain) {
$policy->addSource($domain->Source, $domain->Domain);
}
}
/**
* @param CSPBuilder $policy
* @param array $config
* @throws Exception
*/
protected function addInlineJSPolicy($policy, $config): void
{
if ($config['script-src']['unsafe-inline']) {
return;
}
if (CSPBackend::config()->get('useNonce')) {
$policy->nonce('script-src', $this->getNonce());
}
$inline = static::$inlineJS;
foreach ($inline as $item) {
$policy->hash('script-src', "//<![CDATA[\n{$item}\n//]]>");
}
}
/**
* @return null|string
*/
public function getNonce()
{
if (!$this->nonce) {
$this->nonce = Base64::encode(hash('sha512', uniqid('nonce', false)));
}
return $this->nonce;
}
/**
* @param CSPBuilder $policy
* @param array $config
* @throws Exception
*/
protected function addInlineCSSPolicy($policy, $config): void
{
if ($config['style-src']['unsafe-inline']) {
return;
}
if (CSPBackend::config()->get('useNonce')) {
$policy->nonce('style-src', $this->getNonce());
}
$inline = static::$inlineCSS;
foreach ($inline as $css) {
$policy->hash('style-src', "\n{$css}\n");
}
}
/**
* @param array $headers
* @param Controller $owner
*/
protected function addResponseHeaders(array $headers, Controller $owner): void
{
$response = $owner->getResponse();
foreach ($headers as $name => $header) {
if (!$response->getHeader($header)) {
$response->addHeader($name, $header);
}
}
}
/**
* Add the Permissions-Policy header
* @param array $ymlConfig
* @param Controller $controller
* @return void
*/
private function addPermissionsHeaders(mixed $ymlConfig, Controller $controller)
{
$config = Injector::inst()->convertServiceProperty($ymlConfig);
$policies = [];
foreach ($config as $key => $value) {
switch ($key) {
case 'accelerator':
case 'accelerator_policy':
$policy = 'accelerator';
break;
case 'ambient-light-sensor':
case 'ambient_light_sensor':
case 'ambientLightSensor':
$policy = 'ambient-light-sensor';
break;
case 'autoplay':
case 'autoPlay':
$policy = 'autoplay';
break;
case 'battery':
$policy = 'battery';
break;
case 'camera':
$policy = 'camera';
break;
case 'display-capture':
case 'display_capture':
case 'displayCapture':
$policy = 'display-capture';
break;
case 'encrypted-media':
case 'encrypted_media':
case 'encryptedMedia':
$policy = 'encrypted-media';
break;
case 'fullscreen':
case 'fullScreen':
$policy = 'fullscreen';
break;
case 'geolocation':
case 'geoLocation':
$policy = 'geolocation';
break;
case 'interest-cohort':
case 'interest_cohort':
case 'interestCohort':
$policy = 'interest-cohort';
break;
case 'microphone':
$policy = 'microphone';
break;
default:
$policy = false;
}
if ($policy) {
$policies[] = $this->ymlToPolicy($policy, $value);
}
}
$headerAsArray = ['Permissions-Policy' => implode(', ', $policies)];
$this->addResponseHeaders($headerAsArray, $controller);
}
private function ymlToPolicy($key, $yml)
{
$value = [];
if (!empty($yml['self'])) {
$value[] = "'self'";
}
if (!empty($yml['allow']) && !in_array('none', $yml['allow'])) {
$value[] = implode(', ', $yml['allow']);
}
// If it's none, then anything else we did is useless
if (in_array('none', $yml['allow'])) {
$value = ["'none'"];
}
return sprintf('%s=(%s)', $key, implode(' ', $value));
}
/**
* @return bool
*/
public function isAddPolicyHeaders(): bool
{
return $this->addPolicyHeaders ?? false;
}
/**
* Remove https://, trailing slash, etc.
* @param $domain
* @return string
*/
private function trimDomain($domain)
{
$domain = explode('//', $domain);
$domain = end($domain);
[$domain] = explode('/', $domain);
return $domain;
}
}