Advanced Content Security Policy

Browser window with a padlock within it

Content security policies provide developers with tools to safeguard their users from attacks through the DOM. In this post we’ll go over more advanced topics such as comparing a content security policy built with the level one standard with one built with the level two standard. We’ll also go through challenges that you may face when you implement a content security policy in the real world.

In the first part of this series we covered the basics of CSPs. For instance how they work and some basic principles about implementing them. In this post we’ll dive deeper into a few more advanced use cases for CSPs.

This post is based on the material used for a presentation held at Elisa’s Developer Community. A big thank you to Elisa for the invitation to present! If you work at Elisa, check out upcoming events. You will be missing out otherwise.

Reporting Content Security Policy violations

The content security policy spec comes with a reporting feature that allows you to monitor the performance of your CSP. On one hand it allows you to verify that your CSP does not stop the browser from loading required resources. On the other hand you can use it to detect actual attack attempts against your users.

Content security policy violation monitoring is set up with a special directive. Due to the evolving CSP spec, you have two options report-uri or report-to. We’ll talk more about the differences of the CSP levels later.

When Using report-uri

The report-uri directive has been part of the content security policy spec since level 2. It’s simple to configure, has wide browser support and is the current recommended way to configure reporting. To configure reporting with report-uri, you simply provide an URI as a keyword that points to an endpoint that the browser can POST a report object (JSON) to.

Content-Security-Policy:
  report-uri https://example.com/report;

For instance in this example, the browser would POST a JSON object to the endpoint /report from the domain example.com.

When Using report-to

The report-to directive offers a revised API that leverages the browser reporting API and is part of the level 3 content security policy spec. The report-to directive comes with better development tools as it leverages the browser’s reporting API.

However, it’s part of the CSP level 3, which isn’t an official recommendation yet. Therefore the browser support for report-to isn’t complete although the support for it is growing. Its support in CSP related tooling may also be partial or otherwise lacking–for instance Datadog’s CSP features could not ingest the report object the report-to directive produces in 2024.

Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy:
  report-to csp-endpoint;

Setting up report-to based monitoring is a bit more complex. You first setup a reporting endpoint with Reporting-Endpoints and then target that endpoint in your content security policy header. Note that you may still see use of the Report-To header in examples for implementing the report-to CSP directive. The Report-To header has been deprecated and was never supported for instance in Firefox.

Gotchas Between report-to and report-uri

For now, there are two options with report-uri being the one that will be ousted by report-to in future. However, for now you should stick with report-uri as it’s the only option that’s in a spec that’s a recommendation and it has better browser support.

If you want to giver report-to a go, something you should note is that the report object the browser sends is slightly different between report-to and report-uri. When ingesting these objects, you can vary based on the headers the browser sends with the reporting request.

Another thing to keep an eye on are the stricter limitations for the browser reporting API. For instance, whereas the browser will happily report using report-uri when on localhost, you may find that you need to setup https to make report-to do the same.

Differences Between Level 1 and Level 2

The CSP level 2 introduced many quality of life improvements. For instance new directives and the reporting feature. However, maybe the most notable practical change was the introduction for better controls over inline JavaScript execution. Today we’ll mostly focus on these changes.

Something to note here is that CSPs have been designed so that they are backwards compatible. So you should be able to implement a level 2 CSP so that browsers that only support a level 1 CSP do not break. A fair warning: in some instances understanding all the nuance that goes into building a level 2 CSP that works sensibly for browser that only support CSP level 1 can be quite tricky (and the end result might remind you of a house of cards).

Differences in Control Over Inline Execution

One of the most important purposes of a CSP is to mitigate cross site scripting attacks. A CSP approaches this issue by providing tools that allow JavaScript execution to be controlled. Remember that the existence of a CSP disallows inline JavaScript execution. When using the level 1 spec, you can only allow all or no inline JavaScript. In practice many sites relying on a wider variety of JavaScript based tools had to allow inline JavaScript execution. In essence this meant the use of the unsafe-inline keyword which greatly diminishes the protections a CSP provides against a cross site scripting attack. When the unsafe-inline keyword is used, any JavaScript can execute inline. This provides a straight forward vector for cross site scripting attempts.

One of the main benefits of CSP level 2 are its features that allow more fine grained control over inline JavaScript execution. The level 2 recommendation introduces nonces and hashes which can be used to allow certain known inline JavaScript to execute while disallowing others.

Hashes

When using a hash, you hash the content of the inline JavaScript with some hashing algorithm. You then provide the hash result as part of the content security policy header. The browser will hash the contents of inline scripts and compare the results with the allowed hashes in the header. If the hash the browser generates of the content has not been allowed in the header; the execution of the script is disallowed.

Let’s take a simple inline JavaScript as an example

<script>
  console.log('Inline code');
</script>

By hashing the content of this script, we can create a stable identity for it. When the content of the script changes, so does its hash. In the case of this content, a base64 encoded hash generated with the SHA-256 algorithm is o/voxd+WpWcDI+rtC7991/BszQpqMN6E8iyBzbAOrfg=. The base64 encoding ensures that the hash is header compatible (does not for instance include any special characters). We can tell the browser to allow scripts with this hash to execute by including the hash in our content security policy with the sha256- prefix:

Content-Security-Policy:
  script-src 'sha256-o/voxd+WpWcDI+rtC7991/BszQpqMN6E8iyBzbAOrfg=';

Note that when the hash is included in the CSP, all scripts with this specific hash can execute. In that sense this method does not allow a specific script element, but a specific inline content that can be executed. Although we talked about inline execution, hashes can also allow content from external sources.

Subresource Integrity

Let’s take a small detour here. We’ll briefly talk about subresource integrity (SRI) as it also uses hashes to improve security. How does it differ or work with hashes?

You can implement SRI by adding the integrity attribute to a script element. The value of this attribute should be a hash of the content of the script, much in the same vein as with CSP hashes. The browser then compares the hash with the hash it itself generates. If the content is different to what the hash hints at, the browser considers the script to be compromised and disallows it.

Do you then need to use hashes at all? In short, you do. SRIs ensure that the content hasn’t been tampered with while it travels to the user’s browser. For instance a CDN hasn’t been compromised to deliver compromised scripts. A script that has been injected for a cross site script could provide a valid integrity attribute and therefore be allowed by the SRI check and still be malicious code. By implementing SRI you ensure that the code has not been tampered with during transit, but a SRI does not ensure that the code is relevant for your website.

Nonces

Nonces are an alternative method for marking scripts as allowed. A nonce is a random value that’s only ever used once. In the context of CSPs, it means a value that’s unique between all responses of a web page. The CSP related nonce for a given response is communicated in the CSP headers with the nonce- prefix. The scripts that are allowed should then be marked with the nonce attribute with the nonce for that request as the attribute’s value. Whereas a hash allows specific script contents to execute, nonces allow the content of specific script elements to be executed.

If we continue with the previous example, we’d amend the script to include the nonce attribute:

<script nonce="Wvxt2zDk6kS1H6w4">
  console.log('Inline code');
</script>

You have much leeway in the way you generate your nonce. If you are using a node based stack, you could use the builtin crypto module. Note that although a nonce implies a number, the value doesn’t have to be a number. Any output works as long as it’s random and different for each response. The result should be safe to include in a header, e.g. not have special characters. An easy way to ensure safety is to base64 encode the generated value.

However you decide to generate your nonce, you should include it into the CSP header with the nonce- prefix.

Content-Security-Policy:
  script-src 'nonce-Wvxt2zDk6kS1H6w4';
Pros and cons of using a nonce

Using a nonce is more complex than using a hash, because it requires more from the stack you are using to serve your web application. Whereas with hashes it’s sufficient to only modify the CSP header, when using nonces you also have to modify the HTML document.

However, once you have setup nonces, they may be easier to maintain. You only need a single nonce that can then be used by all scripts. When using hashes, you’d need a separate hash generated for each script content which can be verbose and cumbersome to maintain.

Additionally nonces allow you to work around a key limitation in hashes–scripts whose content may change without announcement. Whether we like it or not, there are still many tools in use that pull in scripts that are not versioned and may change at any time (for instance consent tools). Using a hash for these scripts simply is not possible as the hash would break when the content of the script changes. Nonces are an alternative approach that works in these cases.

The strict-dynamic keyword

The strict-dynamic keyword affords nonces and hashes the power to give a transitive permissions. In essence this means that a script that’s allowed by a nonce or a hash can execute other scripts that are not marked with a nonce or allowed by a hash.

Let’s assume we have quite a common scenario where the webpage has Google Tag Manager (GTM) configured. GTM has been setup to pull in another script to the site–let’s imagine it’s Google Analytics for instance. If the script that pulls in GTM to the site is marked as allowed with a nonce or a hash, and the strict-dynamic keyword is in use, an to execution of the Google Analytics script by the GTM script will be allowed.

The use of the strict-dynamic keyword makes for a CSP that’s easier to maintain. The caveat is that you give allowed scripts the trust to execute other scripts at will. You need to be able to trust the scripts for this arrangement to be safe.

Content Security Policy Level 3

The level 3 spec is currently a working draft that’s still evolving. It introduces further changes like the reporting API overhaul we talked about earlier, some new directives and trusted types. However, these features are not recommendations yet and are still going over iteration. For that reason we won’t be covering CSP level 3 features in depth.

Building a Content Security Policy

A good heuristic for building a content security policy is to listen to the browser! First setup a basic content security policy and the access your application. The browser will highlight issues in the console. It will tell which resources or actions it blocked and will give you instructions on how to remedy the issues.

When It is Script Execution: When the browser blocks a script from executing, favour the use of nonces and hashes to allow execution. In the long term, this makes for a CSP that’s easier to maintain and for that reason, also more secure. Consider whether the strict-dynamic makes for a good tradeoff between security and maintainability. Try to avoid using unsafe-inline and unsafe-eval.

When It is a Special Directive: In some cases you may have previously limited allowed behaviours with a special directive. In that case weigh your options. Most of the time you should make the adjustment the browser advises.

When It’s a Resource That’s From a Source That’s Not Allowed: If the source a resource is loaded from is not allowed, you have to amend your CSP to allow that source. In these case you’d most likely use a host-source expression. Be as specific as you can. Avoid allowing entire domains.

Once you have a CSP that you think works, you should test it out. The content of the CSP can differ slightly between different app versions–for instance between production and development instances. It’s easy to miss required resources in general if you pull in any kind of tracking scripts into the client. A good way to test out your CSP is to setup monitoring and then publish the CSP in report only mode. You can use your CSP in report only mode by publishing it in the Content-Security-Policy-Report-Only header. The browser will not disallow any behaviour the CSP would disallow, but it’ll make the checks and report any violations. This way you can gather data about how your CSP works and make adjustment before enforcing it.

Challenging Real World Scenarios

We’ve now went over some tools and practices that should help you wrangle content security policies. Let’s next go over some real world scenarios where CSPs may yet falter.

Legacy Tools

In the past, a common pattern has been to pull in features onto a webpage by including a script in the header or body that leverages all tools necessary to get its job done. In the past little, consideration existed for how these scripts should operate. For instance, some of them may use the eval function or lack proper versioning.

When a script uses eval, you are forced to allow the unsafe-eval keyword. Allowing eval diminishes protections against cross site scripting attacks considerably. You can mitigate the impact of the inclusion of this keyword by further configurations that limit the use of the eval function to trusted scripts, but it’s likely that the inclusion of the keyword will still get tagged in security audits.

Without proper versioning, you can’t mark a third party script as secure with a hash. That can really limit your options when you are using a stack to serve your application that doesn’t have good support for nonces.

Some old tools have used patterns that are inherently insecure and as such incompatible with a secure CSP. They may find it difficult to migrate their solutions so that they can be used on webpages with a secure CSP and as such there are still example of such legacy tools that are difficult to use with a strict CSP.

Portal Scripts

Especially some tracking scripts are “portal” to all kinds of sources. For instance Criteo is a service that dynamically pulls in tracking tools by negotiation that happens on its platform. That means that the content the script pulls in may change day by day.

Although you could theoretically allow script execution from the dynamic sources Criteo pulls in, many of the sources and behaviours it pulls in can not be covered with script execution alone. Criteo for instance pulls in tracking pixels–images that gather tracking data from users–whose source must be explicitly allowed if all image sources should not be allowed. Even if it’s only script execution, the script might try to POST data to sources that would need to be allowed in the connect-src directive.

In essence these kinds of “portal scripts” force you to create very complex CSPs or very lax CSPs. Neither is a very good option.

Aggressive Caching and Nonces

If you want to cache aggressively, for instance the kind of static caching that we’ve covered before, it’s not always easy to use a nonce. The idea of serving a cached web page is to reuse the result a server generates. However, a nonce should be unique for each request–a nonce should not be cached. This makes caching more convoluted. In essence you’d need some kind of hybrid model where most of the page is cached, but you can still inject some dynamic values. Even when it comes to some popular and relatively recent framework, this may end up being “workaround” territory.

Let’s take Next’s static pages as an example. Let’s specifically talk about the older pages router and its pages that use the getStaticProps function. In these cases Next generates a static HTML document once and then serves it multiple times. Each generated HTML document will get a fixed nonce which significantly diminishes the protections a nonce should provide. Any kind of caching trick, for instance the one we talk about here, won’t cut it because a new HTML document with a unique nonce has to be generated each time the webpage is served. With creative use of edge functions you might be able to work around this limitation. The app router has a documented way for implementing nonces.

In these scenarios the use of nonces might not be possible. You have to make due with hashes instead. However, as we went over before, this may be difficult with some older legacy tools. This is an important takeaway when it comes to tooling around CSPs. The stack you use to server your webpage and the tools you use on it may not have been designed to work with a strict CSP even if the are relatively recent. It’s perfectly possible that you arrive at an impasse where one tool renders approach A unfeasible, another tools makes approach B too cumbersome and you are left with option C which is a pain for you to maintain.

Summary

The current content security spec provides many great features that have made it a lot easier to create a maintainable CSP. However, some obstacles still remain and it can still be difficult to craft a CSP for a more complex site that’s safe and easy to maintain. In the future new CSP features such as trusted types should provide developers with more tools to built robust CSPs. However, some fundamental obstacles still remain–such as how nonces apply special requirements for static sites and the limitations legacy tools create.

If you want to hear more about web development related topics, follow me on LinkedIn to get informed when I post new content.