Last active
February 10, 2020 23:08
-
-
Save wilr/ce19a925ddb1073fb9e9500bdf78a990 to your computer and use it in GitHub Desktop.
Secure Silverstripe Controller Extension
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
use SilverStripe\Control\Director; | |
use SilverStripe\Control\HTTP; | |
use SilverStripe\Security\Member; | |
use SilverStripe\Core\Extension; | |
/** | |
* Implements Content-Security-Policy and other security headers on the | |
* controllers. | |
* | |
* http://en.wikipedia.org/wiki/Content_Security_Policy | |
* http://www.html5rocks.com/en/tutorials/security/content-security-policy/ | |
* http://bens.me.uk/2012/content-security-policy | |
*/ | |
class SecureControllerExtension extends Extension | |
{ | |
private $cacheOptions = null; | |
public function onAfterInit() | |
{ | |
$this->setExtraHeaders(); | |
$this->addCacheHeaders(); | |
} | |
public function setExtraHeaders() | |
{ | |
$response = $this->owner->getResponse(); | |
if ($response && $this->owner->supportsCSP()) { | |
$sites = 'https://www.website.com'; | |
$base = rtrim(Director::absoluteBaseURL(), '/'); | |
if (strpos($sites, $base) === false) { | |
$sites .= ' '. $base; | |
} | |
$baseWithSlash = rtrim(Director::absoluteBaseURL(), '/') . '/'; | |
if (strpos($sites, $baseWithSlash) === false) { | |
$sites .= ' '. $baseWithSlash; | |
} | |
$csp = "default-src $sites;"; | |
$csp .= " base-uri $sites;"; | |
$csp .= " frame-ancestors $sites;"; | |
$csp .= " style-src 'unsafe-inline' $sites https://*.gstatic.com https://api.addressfinder.io https://tagmanager.google.com https://optimize.google.com;"; | |
$csp .= " script-src 'unsafe-inline' $sites https://*.gstatic.com https://api.addressfinder.io https://www.googletagmanager.com https://fonts.googleapis.com https://*.google-analytics.com http://*.google-analytics.com http://tagmanager.google.com https://optimize.google.com http://*.hotjar.com https://*.hotjar.com https://code.jquery.com 'unsafe-eval';"; | |
$csp .= " img-src $sites 'self' data: https://*.google-analytics.com http://*.google-analytics.com https://*.swagger.io https://optimize.google.com https://*.hotjar.com;"; | |
$csp .= " font-src $sites https://fonts.gstatic.com http://*.hotjar.com https://*.hotjar.com;"; | |
$csp .= " object-src $sites 'self';"; | |
$csp .= " frame-src $sites 'self' data: https://*.youtube-nocookie.com https://*.youtube.com https://optimize.google.com https://www.googletagmanager.com/ns.html https://*.hotjar.com;"; | |
$csp .= " child-src $sites https://*.youtube-nocookie.com https://*.youtube.com https://optimize.google.com https://www.googletagmanager.com/ns.html https://*.hotjar.com;"; | |
$csp .= " connect-src $sites https://api.addressfinder.io https://www.google-analytics.com/ http://*.hotjar.com:* https://*.hotjar.com:* ws://*.hotjar.com wss://*.hotjar.com;"; | |
$csp .= " form-action $sites 'self';"; | |
$this->owner->invokeWithExtensions('updateExtraHeaders', $csp); | |
$response->addHeader('Content-Security-Policy', $csp); | |
} | |
if ($response) { | |
$headersToSet = [ | |
'X-Frame-Options' => 'SAMEORIGIN', | |
'X-XSS-Protection' => '1; mode=block', | |
'X-UA-Compatible' => 'IE=edge', | |
'X-Content-Type-Options' => 'nosniff', | |
'Content-Type' => 'text/html; charset=utf-8', | |
'Content-language' => 'en-NZ', | |
]; | |
if (!Director::isDev()) { | |
$headersToSet['Strict-Transport-Security'] = 'max-age=15768000; includeSubDomains'; | |
} | |
foreach ($headersToSet as $header => $value) { | |
$response->addHeader($header, $value); | |
} | |
} | |
} | |
public function supportsCSP() | |
{ | |
$agent = strtolower($_SERVER['HTTP_USER_AGENT']); | |
if (strpos($agent, 'safari') !== false) { | |
$split = explode('version/', $agent); | |
if (isset($split[1])) { | |
$version = trim($split[1]); | |
$versions = explode('.', $version); | |
if (isset($versions[0]) && $versions[0] <= 5) { | |
return false; | |
} | |
} | |
} | |
return true; | |
} | |
/** | |
* Causes the response to the current request to be cached. This takes | |
* advantage of tier 1 caching as defined by CWP. Only use this function | |
* if you really understand the implications. $options is a map of | |
* simplified caching options, as follows: | |
* "max-age" If non-zero, cache headers are set to more | |
* aggressive Incapsula caching, with a max age of this many seconds. If | |
* zero, cache headers are set to be completely dynamic, disabling all | |
* caching. | |
* vary-on-cookies" If true, "cookie" is added to the vary header | |
* (along with x-Forwarded-Protocol) If false, omits "cookie" from vary | |
* header. Default is false. | |
* "modified" If provided, this is a timestamp that identifies when the | |
* page was last modified. If not provided, it defaults to the value | |
* returned by $page->getModificationTimestamp(). | |
*/ | |
public function cacheRequest($options) | |
{ | |
$defaultOptions = array( | |
'max-age' => 60, | |
'vary-on-cookies' => false | |
); | |
$this->cacheOptions = array_merge($defaultOptions, $options); | |
} | |
/** | |
* Add cache headers from the options in $this->cacheOptions ok, we want | |
* to cache this response in infrastructure. Override the framework's | |
* headers. This relies on current framework implementation, which will | |
* set header for us, which we are going to overwrite. | |
*/ | |
protected function addCacheHeaders() | |
{ | |
$response = $this->owner->getResponse(); | |
// Determine max age | |
if (isset($this->cacheOptions['max-age'])) { | |
$maxAge = intval($this->cacheOptions['max-age']); | |
} else { | |
$maxAge = 0; | |
} | |
if ($maxAge > 3600) { | |
// cap it at 1 hour to be safe, as CMS users can change some TTLs. | |
$maxAge = 3600; | |
} | |
// Determine vary on cookies | |
$varyOnCookies = false; | |
if (isset($this->cacheOptions['vary-on-cookies'])) { | |
$varyOnCookies = $this->cacheOptions['vary-on-cookies']; | |
} | |
// Get the modification timestamp if it's present. | |
if (isset($this->cacheOptions['modified'])) { | |
$modificationTimestamp = $this->cacheOptions['modified']; | |
} else { | |
if ($this->owner->hasMethod('getModificationTimestamp')) { | |
$modificationTimestamp = $this->owner->getModificationTimestamp(); | |
} else { | |
$modificationTimestamp = null; | |
} | |
} | |
// Always add last modified | |
if ($modificationTimestamp) { | |
$response->addHeader('Last-Modified', HTTP::gmt_date($modificationTimestamp)); | |
} | |
if ($maxAge > 0 && !Member::currentUserID()) { | |
// Non-zero max age means we want the response cached. We only cache if the user is not logged in. | |
$response->addHeader('Cache-Control', 'max-age=' . $maxAge . ', must-revalidate, no-transform, public'); | |
$response->addHeader('Pragma', ''); | |
$vary = 'X-Forwarded-Protocol'; | |
if ($varyOnCookies) { | |
$vary = 'Cookie, ' . $vary; | |
} | |
$response->addHeader('Vary', $vary); | |
} else { | |
// no caching | |
$response->addHeader('Cache-Control', 'public, max-age=0, must-revalidate, no-transform'); | |
$response->addHeader('Pragma', 'no-cache'); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment