Photo of Christoph Rumpel

Content Security Policy 101

As more and more services get digital these days, security has become a significant aspect of every application. Especially when it comes to third-party code, it is tough to guarantee safety. But in general, XSS and Code Injection is a big problem these days. Content Security Policy provides another layer of security that helps to detect and protect different attacks. Today, I will introduce this concept and its main features, as well as show real-world examples.

Web Application Vulnerabilities

Security is crucial to every web application, but it is a difficult and complex topic as well. It is like a backup: You don't think about it until it is too late. But when It's too late, you're screwed! There will be consequences, and it can cost you clients, money, or also harm people when you think about IOT.

But when we look at all the prominent companies that get hacked every day, It tells us that securing an application is extremely difficult. Nevertheless, there is no excuse for not trying. There will always be ways to hack a site, but we need to make it as difficult as possible.

Most of the attacks, we need to prevent as web developers, are Cross-Site-Scripting (XSS) related. With XSS, the attacker can inject malicious scripts or styles to your website. This is a big problem because browsers trust the code that is coming from your site. There is no chance for them to detect malicious scripts, so they can easily get any cookie, session tokens or other sensitive information. It can even change the look of the site to trick you to enter other useful information. Summarized, XSS is big and common web application problem.

Welcome CSP

CSP stands for Content Security Policy, which is a W3C specification offering possibilities to reduce XSS attacks. It allows you to define an HTTP header that tells the browser which resources are allowed to be loaded. The header consists of the name Content-Security-Policy and one or multiple policies <policy-directive>; <policy-directive>. Let's take a look at a basic example:

report-uri https://christophrumpel.report-uri.com/r/d/csp/enforce;base-uri 'self';connect-src 'self';default-src 'self';form-action 'self' christoph-rumpel.us5.list-manage.com;img-src 'self' *.google-analytics.com *.gravatar.com *.facebook.com screenshots.nomoreencore.com;media-src 'self';object-src 'self';script-src 'self' *.googletagmanager.com *.google-analytics.com 'sha256-2eu3x9C6JPt7NvPk4iAcvrQ2g+UHBEyUsilOqkWukiU=' *.facebook.net 'sha256-P70IONn7LzR0v1pnyUiwOX+9oJzqbc7ZGp+eujcwZsE=';style-src 'self' fonts.googleapis.com 'sha256-wBw6YmX3Lhxkl6S8PnlNxVcwALnNr89VRt5yOv5yQqE=';font-src fonts.gstatic.com fonts.googleapis.com data:;frame-src *.facebook.com

It defines a default policy which only allows resources being loaded from the site's origin. (excluding subdomains) This is what self stands for. The type of the policy is called directive. The default one is being triggered if there is no other more specific one available. We could and will also define particular directives for scripts, images, fonts and more.

Note: Find a list of all possible directives here

CSP in action

It is always more useful to demonstrate something new while looking at a real example. We will use my blog as our testing object. If you check the response headers of my site, you will find this mess of policies. Our goal is to recreate all the policies together.

report-uri https://christophrumpel.report-uri.com/r/d/csp/enforce;base-uri 'self';connect-src 'self';default-src 'self';form-action 'self' christoph-rumpel.us5.list-manage.com;img-src 'self' *.google-analytics.com *.gravatar.com *.facebook.com screenshots.nomoreencore.com;media-src 'self';object-src 'self';script-src 'self' *.googletagmanager.com *.google-analytics.com 'sha256-v7B5PDsgEuAa8xkD6IdvngTMioN9v+6o0H1fZ0RlfaM=' *.facebook.net 'sha256-P70IONn7LzR0v1pnyUiwOX+9oJzqbc7ZGp+eujcwZsE=' 'sha256-pjpfKUw4LCwwr0e2/ABrZCkRUktaJDW5Wmg7psjFXLs=';style-src 'self' fonts.googleapis.com 'sha256-wBw6YmX3Lhxkl6S8PnlNxVcwALnNr89VRt5yOv5yQqE=';font-src fonts.gstatic.com fonts.googleapis.com data:;frame-src *.facebook.com *.youtube.com

Preparations

First things first, we need to add a CSP header to our outgoing requests. My blog is based on Laravel, so I will use a Laravel middleware to add this header.

php artisan make:middleware AddCspHeader
Note: If you don't use Laravel or even PHP just check how to add a response header for your application. The concept is still the same.

You can find the middleware under app/Http/Middleware. Change the default code to:

public function handle($request, Closure $next)
{
    $response =  $next($request);

    $response->headers->set('Content-Security-Policy', 'default-src none');

    return $response;
}

This will set the CSP header and be our default policy. To make the middleware work we also need to add it in app/Http/Kernel.php.

protected $middlewareGroups = [
        'web' => [
        // other middlewares
            AddCspHeader::class
        ],

       // other stuff...
    ];

Also, make sure to include the namespace use App\Http\Middleware\AddCspHeader; at the top of the file. When I refresh my page, I now see three things:

1. My blog now only consists of un-styled text.

Screenshot showing almost empty blog of mine

2. Almost all resources are blocked. Screenshot showing all CSP errors

3. We managed to add the new response header for CSP in the request's response.

Screenshot showing our first CSP response header

Getting the styles back

It gives us a great starting point. All the resources are blocked, and we need to activate one by one. First, we adapt our default-src directive. As the default, we want everything allowed coming from the same origin. We can do this by using the self source keyword for our header.

$response->headers->set('Content-Security-Policy', "default-src 'self'");

That's enough to bring my styles back on the blog since they are loaded from the same origin. At the same time images are also available again. Notice, that we need to use apostrophes around the self keyword.

Getting the fonts back

Next, you have probably noticed that the fonts were rejected as well. I use Google Fonts, and they are loaded from another origin. Actually, there two Google origins we need to add: fonts.gstatic.com and fonts.googleapis.com. Both get added to the font-src directive, and one of them to the style-src directive as well.

$response->headers->set('Content-Security-Policy', "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.googleapis.com fonts.gstatic.com");

After the directive name, you can add multiple resources, separated by a space. But we are not finished here. Google also uses data: URIs and they need to be allowed as well.

$response->headers->set('Content-Security-Policy', "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.googleapis.com data: fonts.gstatic.com");

Now the fonts are back on my blog.

Getting the scripts back

Another look at the console shows that there are still some errors remaining.

Screenshot showing remianing CSP analytics errors

This is because I use Google Analytics on my blog and there are scripts required to make it work. So we add a script-src directive and allow scripts from the site's origin and googletagmanager.com.

$response->headers->set('Content-Security-Policy', "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.googleapis.com data: fonts.gstatic.com; script-src 'self' *.googletagmanager.com");

The * means that we allow the mentioned origin and all its subdomains as well.

Still, one error is remaining: "Refused to execute inline script because it violates...". This is due to the Google Analytics snippet in my footer. It is an inline-script we haven't approved yet. We could do that by adding the unsafe-inline source to our script-src directive. But disallowing inline styles and scripts is one of the CSP's best feature. So we don't want to allow them.

Another approach is to use a nonce. It works like this. You first add a nonce to the inline script you want to use.

<script nonce="1234">
  // ...
</script>

Then you append the same value to the directive, with the nonce keyword. This way you make sure that only inline scripts get executed, that you control.

$response->headers->set('Content-Security-Policy', "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.googleapis.com data: fonts.gstatic.com; script-src 'self' *.googletagmanager.com 'nonce-1234'");
Note: The nonce is simplified for the example here. In production code you should use a dynamically create value. Later in this article, I will show you a better approach.

Finished?

No errors in the console mean we're fine right? No! I made this mistake twice, but I don't want you to. Right now we have particular rules for our resources defined. As a result, if we forget about any, it will break something. In my case, I forgot about my book page. 😲

It is not a separate website, and therefore the same CSP rules are valid. What is different there to my blog is, that I use a little chatbot there that I built. It needs the Facebook JavaScript SDK and multiple additions to our CSP rules to work.

1. NONCE

The nonce needs to be added to the Facebook SDK inline script.

2. Images

The script also loads some images. We need to allow a new origin.

3. Iframe

Facebook adds an iFrame to the page, and there is a separate directive for it called frame-src. Here, the origin *.facebook.com needs to be added.

4. More styles

The SDK also uses inline styles. Normally we could add a nonce to style tags, as we did with the script ones. This is not possible here, and as a result, I had to allow inline styles with unsafe-inline. I had a nice discussion with some friends on Twitter on that specific topic. If you got a better solution, please contact me.

Update: In the article CSP, hashing, and Turbolinks I explain the CSP hashing feature. Instead of using unsafe-inline I can now hash the styles and place it in the header.

$response->headers->set('Content-Security-Policy', "default-src 'self'; style-src 'self' fonts.googleapis.com 'unsafe-inline'; font-src fonts.googleapis.com data: fonts.gstatic.com; script-src 'self' *.googletagmanager.com *.facebook.net 'nonce-1234'; img-src 'self' *.gravatar.com *.facebook.com data:; frame-src *.facebook.com");

Finally everything was working

NOT! I ran into another problem. This time I forgot about the newsletter signup form on my book page. You don't see any errors when you load the page, but you will when you try to submit the form. With the rules from above, it would work, but before I had this is my CSP header: form-action 'self'. It only allows form submissions to the current origin. However, I am posting to MailChimp. As a result, my newsletter form wasn't working for a day as well! It was serious. Right now it is crucial for me to tell as many people as possible about this project. I can't afford to lose them because of a broken form, which was already working before.

Everything was fine again, after including my MailChimp domain to the form-action directive. Everything together now looks like this:

$response->headers->set('Content-Security-Policy', "default-src 'self'; style-src 'self' fonts.googleapis.com 'unsafe-inline'; font-src fonts.googleapis.com data: fonts.gstatic.com; script-src 'self' *.googletagmanager.com *.facebook.net 'nonce-1234'; img-src 'self' *.gravatar.com *.facebook.com data:; frame-src *.facebook.com; form-action self christoph-rumpel.us5.list-manage.com");

Refactoring

I have two problems with the current CSP integration. First, I don't have dynamic nonces yet. We cannot use static ones. That's not secure at all. Secondly, this policy string is a big mess! It is difficult to read and to see the policy's purpose. That's why I switched to a CSP package. Of course, Spatie already got a package called Laravel CSP. You could also say I spatified the integration.

Package setup

Here is how it goes. Install the package via composer.

composer require spatie/laravel-csp

Then publish the config file.

php artisan vendor:publish --provider="Spatie\Csp\CspServiceProvider" --tag="config"

And also add the new middleware to the web group.

protected $middlewareGroups = [
        'web' => [
            // other middlewares
             \Spatie\Csp\AddCspHeaders::class,
        ],

       // other stuff...
    ];

In the published csp.php config file, take a look at the policy key. By default it is set to the package's basic policy class \Spatie\Csp\Policies\Basic::class. Instead of this class, we define a custom class.

<?php

use App\Services\Csp\Policies\CustomPolicies;

return [

    /*
     * A policy will determine which CSP headers will be set. A valid CSP policy is
     * any class that extends `Spatie\Csp\Policies\Policy`
     */
    'policy' => CustomPolicies::class,

    // other stuff...
];

Create this class now. It must extend the Policy class from the package.

<?php

namespace App\Services\Csp\Policies;

use Spatie\Csp\Policies\Policy;

class CustomPolicies extends Policy
{
    public function configure()
    {
        
    }
}

In the configure method you define the policies. You could extend this class from the Spatie`s Basic class, but I prefer to set the default rules myself. Here is a simple example showing how to set directives with this package.

public function configure()
{
    $this->addDirective(Directive::SCRIPT, 'www.google.com');
}

There are constants in the Directive class for every possible directive. Additionally, you can define multiple sources with an array instead of the string.

public function configure()
{
    $this->addDirective(Directive::SCRIPT, ['self', 'www.google.com']);
}

Custom policies

Now I want to set my default policies. I create methods to separate the chunks.

public function configure()
{
    $this->setDefaultPolicies();
}

protected function setDefaultPolicies()
{
    return $this->addDirective(Directive::BASE, 'self')
        ->addDirective(Directive::CONNECT, 'self')
        ->addDirective(Directive::DEFAULT, 'self')
        ->addDirective(Directive::FORM_ACTION, 'self')
        ->addDirective(Directive::IMG, 'self')
        ->addDirective(Directive::MEDIA, 'self')
        ->addDirective(Directive::OBJECT, 'self')
        ->addDirective(Directive::SCRIPT, 'self')
        ->addDirective(Directive::STYLE, 'self');
}

Normally, all the rules above would be the same as setting default-src 'self'. The problem is, this is only a fallback policy. Once you add a specific directive, you would need to add self there again, for every directive. This is why I define all of them right from the beginning.

Next, I create similar methods for all rules I need.

public function configure()
{
    $this->setDefaultPolicies();
    $this->addGoogleFontPolicies();
    $this->addGoogleAnalyticsPolicies();
    $this->addGravatarPolicies();
    $this->addFacebookChatbotPolicies();
    $this->addMailChimpPolicies();
}
    
private function setDefaultPolicies()
{
    return $this->addDirective(Directive::BASE, 'self')
        ->addDirective(Directive::CONNECT, 'self')
        ->addDirective(Directive::DEFAULT, 'self')
        ->addDirective(Directive::FORM_ACTION, 'self')
        ->addDirective(Directive::IMG, 'self')
        ->addDirective(Directive::MEDIA, 'self')
        ->addDirective(Directive::OBJECT, 'self')
        ->addDirective(Directive::SCRIPT, 'self')
        ->addDirective(Directive::STYLE, 'self');
}

private function addGoogleFontPolicies()
{
    $this->addDirective(Directive::FONT, [
        'fonts.gstatic.com',
        'fonts.googleapis.com',
        'data:',
    ])
        ->addDirective(Directive::STYLE, 'fonts.googleapis.com');
}

private function addGoogleAnalyticsPolicies()
{
    $this->addDirective(Directive::SCRIPT, '*.googletagmanager.com')
        ->addNonceForDirective(Directive::SCRIPT);
}

private function addGravatarPolicies()
{
    $this->addDirective(Directive::IMG, '*.gravatar.com');
}

private function addFacebookChatbotPolicies()
{
    $this->addDirective(Directive::SCRIPT, '*.facebook.net')
        ->addDirective(Directive::IMG, '*.facebook.com')
        ->addDirective(Directive::FRAME, '*.facebook.com')
        ->addDirective(Directive::STYLE, 'unsafe-inline');
}

private function addMailChimpPolicies()
{
    $this->addDirective(Directive::FORM_ACTION, 'christoph-rumpel.us5.list-manage.com');
}

Dedicated methods now separate all rules. This way, it is much easier to say what a policy is for. And of course, changing them is much more straightforward as well. Thanks, Spatie! It seems I need to send another postcard 😃 The outputted header though, is still the same as we already had before.

Reporting

There is one thing we haven't covered yet. It's reporting. This CSP concept can help us in two ways.

1. Testing

For testing purposes, we can use a different CSP header name: Content-Security-Policy-Report-Only It will only show errors in the browser's console, but resources are still loaded. This is perfect for testing. To make that possible in the Laravel CSP package, just add a policy class to the key report_only_policy. The best way is to test the policy first before you add your class to the actual policy key.

2. Notifications

It is also possible to report policy violations. Just add the report-uri directive, followed by a URL. Your browser will send a JSON request to this URL to report attempts to violate the CSP.

Content-Security-Policy: default-src 'self'; report-uri http://reportcollector.example.com/collector.cgi

In Spatie's package, we can set this URL or define an environment variable.

'report_uri' => env('CSP_REPORT_URI', ''),

You then need to create an endpoint to collect those notifications, or you can use ReportURI. It is a service that receives all of your site's CSP violations. You just need to create an account, grab the URL you get, and place in your CSP header or our package's config file.

Note: Unfortunately, the report-uri directive is deprecated. It still works in some browsers, but there will be a new one. Read more here.

Browser Compatibility

What I didn't mention yet is, that there are three W3C versions of CSP. The third one is in the making right now a W3C Working Draft. Everything from this article works in CSP Level 2. The most modern browsers already support CSP it, except the Internet Explorer. If your users use a browser that only supports CSP 1.0, it could lead to resources not being loaded on your site. NONCES, for example, don't work with CSP 1.0. Make sure to check CSP browser compatibility before using it.

Screenshot of browser support table

Conclusion

Let's say this escalated quickly. From my first touchpoint with CSP to the given article, it was just two weeks. But these policies are exciting and great in securing your site. I just had to dive right in 😁. You should too to get a better understanding, but I promise it will be worth it.

I hope this article could help you with this topic and showed you some great real-world examples. CSP also forces you to think about all your site's resources, and I learned a lot about my own site. Mozilla also got great in-depth articles for further reading on Content Security Policy.

Update: Seems like I wasn't finished here after this article. A day later I had to deal with CSP and response caching and CSP, hashing, and Turbolinks.

Let's stay in touch

Sign up for my newsletter and I will let you know about more content and new projects of mine once a month.