Achieving an A+ Security Score for your Website

Security has always been important for anyone operating a website, but with the introduction of the GDPR and the very real threat (and reality) of astronomical fines, it feels like the stakes have been raised. It’s never been more important to take all the steps available to you to protect your website and your users' data.

There have been loads of new standards and techniques introduced over the past few years which are now widely adopted by the latest browsers, so it really makes sense for web developers and site owners to be making use of them.

Is my site secure?

So the first thing to do is to find out how things stand currently and what can be improved, by running your site through a few online audit tools. There are a number of great resources out there to point you in the right direction.

A good place to start your quest for a more secure site, is the Mozilla Observatory (https://observatory.mozilla.org). I love this tool, but it can be a little harsh as it’s applying the latest standards so don’t get disheartened if you get an ‘F’ score initially - it doesn’t mean your site is super insecure, it just means that there is more you can do to reduce the chance of hackers gaining access.

This tool was invaluable when I was improving the security of our site (18aproductions.co.uk). Every site has it’s own set of challenges when it comes to optimising security - the platform it’s built on, the hosting environment, the PHP version, and the webserver technology all play a part. I’ll go through the main obstacles I came up against and how I delt with them below

Content Security Policy

The top of the Mozilla checklist is implementing a Content Security Policy (CSP) for your site. This is basically a list which tells your browser what content is allowed to run from where and is a great way of reducing the chance of cross-site scripting (XSS) on your website.

I won’t go into huge detail explaining how to create a content security policy here because there are already tools out there to help you such as https://developers.google.com/web/fundamentals/security/csp/. However it’s basically just a list of types of things and the URLs that are allowed to call them.

So I’ll assume at this point that you have a CSP and just need to apply it to your site.

Implementing a Content Security Policy on an existing website

Retro-fitting a CSP to an old website can be time-consuming, but there are a number of approaches you can take to make it less painful.

Our site was built over 10 years+ ago on CodeIgniter. I love CodeIgniter, but it’s not as up to date with the latest standards as other frameworks (such as Laravel), so there’s more you need to do yourself to improve security - you can’t just add a new package with composer and turn it on as with Laravel - at least not in my experience.

If you’re website is running on the Apache webserver with the headers module (https://httpd.apache.org/docs/current/mod/mod_headers.html), the quick and easy way to apply a CSP to your site is to just add a Header to your websites .htaccess file as per the below:


<IfModule mod_headers.c>
	Header set Content-Security-Policy "default-src 'none'; font-src 'self' https://fonts.gstatic.com ; img-src 'self' https://*.18aproductions.co.uk https://*.cloudfront.net https://www.google-analytics.com https://www.googletagmanager.com ; media-src 'self'; script-src 'self' https://code.jquery.com https://www.googletagmanager.com https://*.cloudfront.net https://*.twitter.com https://www.google-analytics.com https://ajax.googleapis.com ; style-src 'self' https://*.cloudfront.net; frame-src 'self' https://www.google.com; frame-ancestors 'self'; form-action 'self'; connect-src 'self' https://www.google-analytics.com; base-uri 'none' "
</IfModule>

This works great, however I found I unfortunately had a lot of inline Javascript (and a little CSS) which then didn’t work. The immediate way round this was to allow ‘unsafe-inline’ and ‘unsafe-eval’ to the list of allowed sources, however as limiting the running on inline Javascript is one of the main objectives of a CSP, this does make it all a little redundant, so I wanted a better approach (also this didn’t help my score and by this point it was all about getting top marks!).

Nonces, Hashes and Meta tags

Then I read this handy articles about nonces and hashes and also found out that, whilst it’s not preferable, CSPs can be defined using a <meta> tag in the <head> of the page https://developers.google.com/web/fundamentals/security/csp/#the_meta_tag. This allowed me a way around the fact I have loads of inline JS, by using my framework to create a nonce on page load and apply it to both the <meta> tag on the page, and also the inline JavaScript code block on page load.

I’m already using a custom templating library with CodeIgniter (the site was built on a version of CodeIgniter before templating was built in as standard), so I added a new function called csp() so I could output the CSP on page load, based off a nice nested array defined in a new csp.php config file, then added in the nonce value and made it available in the template view. It’s pretty easy stuff, but here’s an example of my config file and associated function added to my templating library:

Here’s my new CodeIgniter config file (csp.php) which defines that I want in my policy.


<?php  if ( ! defined('BASEPATH')) exit('No direct script access allowed');

$config['default_content_security_policy'] = 
[
	'default-src' => [
		'none'
	],
	'font-src' => [
		'self',
		'https://fonts.gstatic.com'
	],
	'img-src' => [
		'self',
		'https://*.18aproductions.co.uk',
		'https://*.lightwidget.com',
		'https://*.cloudfront.net',
		'https://www.google-analytics.com',
		'https://www.googletagmanager.com'
	],
	'media-src' => [
		'self'
	],
	'script-src' => [
		'self',
		'https://code.jquery.com',
		'https://www.googletagmanager.com',
		'https://*.lightwidget.com',
		'https://*.thewebguild.org',
		'https://*.cloudfront.net',
		'https://*.twitter.com',
		'https://www.google-analytics.com',
		'https://www.gstatic.com',
		'https://ajax.googleapis.com',
	],
	'style-src' => [
		'self',
		'https://*.thewebguild.org',
		'https://www.gstatic.com',
		'https://*.cloudfront.net'
	],
	// You can’t define frame-ancestors via the tag
	//'frame-ancestors' => [
	//	'none'
	//],
	'frame-src' => [
		'self',
		'https://lightwidget.com',
		'https://www.google.com',
		'https://*.thewebguild.org'
	],
	'connect-src' => [
		'self',
		'https://www.google-analytics.com'
	],
	'form-action' => [
		'self'
	],
	'base-uri' => [
		'none'
	]
];

And here’s the function added to the template library.

(FYI - $this->csp_nonce = base64_encode(time()); was defined in the __construct().)


public function csp()
{
	$this->CI->load->config('csp', TRUE);

	$csp = $this->CI->config->item('default_content_security_policy','csp');
	
	if (empty($csp))
	{
		return;
	}

	$pairs = [];

	foreach($csp as $type=>$values)
	{
		$pairs[$type] = $type;

		foreach($values as $val)
		{
			if ($val == 'none' || $val == 'self' || $val == 'unsafe-inline'  || $val == 'unsafe-eval')
			{
				$val = '\''.$val.'\'';
			}
			
			$pairs[$type] .= ' '.$val;
		}

		if (($type == 'script-src') || ($type == 'style-src'))
		{
			// Add in the nonce
			$pairs[$type] .= ' \'nonce-'.$this->csp_nonce.'\'';
		}

		$pairs[$type] = trim($pairs[$type]);

	}

	return implode('; ', $pairs);
}

So in my template view I can just call:


<meta http-equiv="Content-Security-Policy" content="<?php echo $this->template->csp(); ?>">

And every JavaScript block just needs it’s script tag updating with:


<script type="text/javascript" nonce="<?php echo $this->template->csp_nonce; ?>">
</script>

Disclaimer: This is by no means a complete solution and will probably not suit your needs - it’s just a quick solution which suits my specific needs and allows me to define a nonce alongside appling the policy defined in the config file via the <meta> tag. So I’m including here for illustrative purposes only.

I’m not 100% sure this is a perfect approach as I don’t really see why a hacker couldn’t look at the <meta> tag on the page to determine what to set the nonce for their injected JS code, but this approach seems to satisfy both my browser and the Mozilla testing tool (securityheaders doesn’t seem to recognise a CSP defined as a Meta tag - I guess it’s only looking at headers after all). So hopefully this is all good (comments welcome?). If nothing else I figure it makes life a little harder for anyone trying to cause trouble and that’s what it’s all about after all.

Our site is pretty straight forward and small really, as soon as you’re implementing a CSP on a larger, more complex site your problems increase, so I’d suggest reading up a little more on the topic. https://wiki.mozilla.org/Security/CSP/Specification

Setting ‘Samesite’ Cookies with CodeIgniter

‘Samesite’ cookies are a pretty new thing. Infact, you need the latest version of php (7.3) in order to set this new option in the setcookie() function (https://www.php.net/manual/en/function.setcookie.php). However the lack of these was really hurting my Observatory score so I needed to do something about it.

I was running php 7.2 on my EC2 instance (webserver), so the first challenge was upgrading php which I won’t go into here, but suffice to say - I got it done.

The next problem was that CodeIgniter didn’t have support for this feature in it’s system classes. So there was more work to be done.

After a little investigative work I found it wasn’t too big a deal, I just had to extend the core/Session and core/Security classes and the library/Session/Session classes with versions of my own using the MY_ approach (https://codeigniter.com/user_guide/general/core_classes.html and https://codeigniter.com/user_guide/general/creating_libraries.html)

I won’t post all the code here, but the general gist was to pass an array of options to the setcookie() function, including a samesite value (either Lax or Strict) (defined in the main config file):

I.e.


if (phpversion() >= 7.3)
{
	setcookie(
		$this->_config['cookie_name'],
		session_id(),
		[
			'expires'			=> (empty($this->_config['cookie_lifetime']) ? 0 : time() + $this->_config['cookie_lifetime']),
			'path'				=> $this->_config['cookie_path'],
			'domain'			=> $this->_config['cookie_domain'],
			'secure'			=> $this->_config['cookie_secure'],
			'httponly'		=> TRUE, // HttpOnly; Yes, this is intentional and not configurable for security reasons
			'samesite'		=> $this->_config['cookie_samesite']
		]
	);
}
else
{
	setcookie(
		$this->_config['cookie_name'],
		session_id(),
		(empty($this->_config['cookie_lifetime']) ? 0 : time() + $this->_config['cookie_lifetime']),
		$this->_config['cookie_path'],
		$this->_config['cookie_domain'],
		$this->_config['cookie_secure'],
		TRUE
	);
}

Setting secure headers for your website


Another really cool online tool is https://securityheaders.com. Using this tool I was able to check if I was passing all the correct headers I needed to, including:

  • Access-Control-Allow-Origin
  • X-Frame-Options
  • X-Content-Type-Options
  • Feature-Policy
  • Referrer-Policy
  • Strict-Transport-Security
  • X-XSS-Protection

Again, using Apache you can define many of these in your .htaccess file, for example:


<IfModule mod_headers.c>
	Header set X-Content-Type-Options nosniff
	Header set X-Frame-Options SAMEORIGIN
	Header set Access-Control-Allow-Origin "self"
	Header always set Referrer-Policy: strict-origin
	Header always set Feature-Policy "microphone 'none'; payment 'none'; sync-xhr 'self' https://www.18aproductions.co.uk"
	Header set X-XSS-Protection: "1; mode=block"
</IfModule>

So that’s all pretty easy peasy and gives your score an easy boost.

Cross Site Request Forgery (CSRF) tokens

One thing you can just ‘turn on’ in CodeIgntier are using Cross Site Request Forgery (CSRF) tokens - Just set:


$config['csrf_protection'] = TRUE;

in your config file. If you have specific URLs you want to exclude, you can add these to your $config['csrf_exclude_uris'] = array();

One thing to watch out for here is that you’re using the form_open() helper on all your forms, if you are it’ll add in your token for you as a hidden field. If you’re just manually opening forms, it won’t and your form submissions will fail.

The other gotcha with CSRF tokens I’ve found is where you send POST requests to your site using jQuery AJAX. You’ll need to manually send the token and value to the backend for checking with PHP. Or the quick solution is to change to GET requests instead as these don’t require the token to be set.

Subresource Integrity

In theory Subresource integrity shouldn’t have been too difficult to implement, however as we pull our assets off a CDN (cloudfront), subresource integrity hashes are required on these resources.

It’s easy enough to grab these from the SRI hash generator tool https://www.srihash.org/ however our site also uses an asset number in order to invalidate the cache when we make a change to the code. I.e. We append a version number to the end, for example:


https://d36noohb37qrwu.cloudfront.net/min/f=a/css/screen.css,a/css/third_party/roboto.css,a/css/third_party/lobster.css,a/css/home/screen.css&42

Not only that, but we have real-time minification of the JS and CSS of the exact URLs pulled in on each page and created ‘on the fly’ as the page loads.

So this would not only mean generating a new hash for every different variation of URL we’re pulling from cloudfront, but also updating every single one when we updated the asset number. Which would make it all completely impractical.

So my solution was to find out how the SRI hashes are generated and make my own, then apply it in realtime via the Template class as with the content security policy.

Thanks to this article https://tenzer.dk/generating-subresource-integrity-checksums/ that wasn’t too difficult to do. Here’s an example of the new function I’ve added to the Template class:


function integrity_checksum($input)
{
	$cachepath =  $this->CI->config->item('cache_path') . 'integrity_checksum-'.md5($input);

	// Do we have a cached version of the checksum available
	if (file_exists($cachepath))
	{
		$hash_base64 = file_get_contents($cachepath);
	}
	else
	{
		$contents = file_get_contents($input);
		$hash = hash('sha256', $contents, true);
		$hash_base64 = base64_encode($hash);

		// Cache the value
		file_put_contents($cachepath, $hash_base64);
	}

	return "sha256-$hash_base64";
}

To try and speed up the process I also cache, to a text file, the hash generated from a given resource URL to make retrieval slightly quicker on subsequent requests.

This could be done much better (for example by loading up all the hashes required in a single request), however for my very simple purposes, this does the job and allows the site to apply subresource integrity without losing too much speed.

I can then use the following in the template view.


<script src="<?php echo $url; ?>" integrity="<?php echo $this->template->integrity_checksum($url); ?>" crossorigin="anonymous" type="text/javascript"></script>

Checking SSL Security

I’ve written about this before, but another important check you should run is the SSL certificate and its implementation on your server.

There are many online tools out there but I like SSLLabs https://www.ssllabs.com/ssltest/analyze.html?d=www.18aproductions.co.uk

This should point you in the right direction to check your web server and certificate are setup correctly. Many of the things it suggests can be implemented at the server configuration level which is easy enough to do with Apache and there are plenty of online guides out there. In my case it was just a matter of going through this guide https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/SSL-on-amazon-linux-2.html

In Summary

So after many, many hours and many more coffees later, I’d managed to boost my score right up to an A+ and Mozilla had no further advice for me https://observatory.mozilla.org/analyze/www.18aproductions.co.uk, which I’m pretty chuffed about. I expect by the time you read this another new acronym / standard will have been introduced and I’ll need to re-write this article, but for the moment I’m enjoying my moment at the top! :)

So if you want to make sure you’re doing all you can to protect your site and your users from hackers, please do give us a shout and we’ll run a few checks for you to see what can be done. Every site is unique and presents it’s own set of challenges, but there may be some easy wins we can implement relatively quickly.

Want to share? Tweet it!

This site uses cookies, your continued use implies you agree with our cookie policy. Dismiss »