Skip to content

Instantly share code, notes, and snippets.

@wilr
Last active February 10, 2020 23:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save wilr/ce19a925ddb1073fb9e9500bdf78a990 to your computer and use it in GitHub Desktop.
Save wilr/ce19a925ddb1073fb9e9500bdf78a990 to your computer and use it in GitHub Desktop.
Secure Silverstripe Controller Extension
<?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