Mitigate cross-site scripting (XSS) with a strict Content Security Policy (CSP)

Lukas Weichselbaum
Lukas Weichselbaum

Browser Support

  • Chrome: 52.
  • Edge: 79.
  • Firefox: 52.
  • Safari: 15.4.

Source

Cross-site scripting (XSS), the ability to inject malicious scripts into a web app, has been one of the biggest web security vulnerabilities for over a decade.

Content Security Policy (CSP) is an added layer of security that helps to mitigate XSS. To configure a CSP, add the Content-Security-Policy HTTP header to a web page and set values that control what resources the user agent can load for that page.

This page explains how to use a CSP based on nonces or hashes to mitigate XSS, instead of the commonly used host-allowlist-based CSPs that often leave the page exposed to XSS because they can be bypassed in most configurations.

Key term: A nonce is a random number used only once that you can use to mark a <script> tag as trusted.

Key term: A hash function is a mathematical function that converts an input value into a compressed numerical value called a hash. You can use a hash (for example, SHA-256) to mark an inline <script> tag as trusted.

A Content Security Policy based on nonces or hashes is often called a strict CSP. When an application uses a strict CSP, attackers who find HTML injection flaws generally can't use them to force the browser to execute malicious scripts in a vulnerable document. This is because strict CSP only allows hashed scripts or scripts with the correct nonce value generated on the server, so attackers can't execute the script without knowing the correct nonce for a given response.

Why should you use a strict CSP?

If your site already has a CSP that looks like script-src www.googleapis.com, it's probably not effective against cross-site. This type of CSP is called an allowlist CSP. They require a lot of customization and can be bypassed by attackers.

Strict CSPs based on cryptographic nonces or hashes avoid these pitfalls.

Strict CSP structure

A basic strict Content Security Policy uses one of the following HTTP response headers:

Nonce-based strict CSP

Content-Security-Policy:
  script
-src 'nonce-{RANDOM}' 'strict-dynamic';
 
object-src 'none';
 
base-uri 'none';
How a nonce-based strict CSP works.

Hash-based strict CSP

Content-Security-Policy:
  script
-src 'sha256-{HASHED_INLINE_SCRIPT}' 'strict-dynamic';
 
object-src 'none';
 
base-uri 'none';

The following properties make a CSP like this one "strict" and therefore secure:

  • It uses nonces 'nonce-{RANDOM}' or hashes 'sha256-{HASHED_INLINE_SCRIPT}' to indicate which <script> tags the site's developer trusts to execute in the user's browser.
  • It sets 'strict-dynamic' to reduce the effort of deploying a nonce- or hash-based CSP by automatically allowing the execution of scripts that a trusted script creates. This also unblocks the use of most third party JavaScript libraries and widgets.
  • It's not based on URL allowlists, so it doesn't suffer from common CSP bypasses.
  • It blocks untrusted inline scripts like inline event handlers or javascript: URIs.
  • It restricts object-src to disable dangerous plugins like Flash.
  • It restricts base-uri to block the injection of <base> tags. This prevents attackers from changing the locations of scripts loaded from relative URLs.

Adopt a strict CSP

To adopt a strict CSP, you need to:

  1. Decide whether your application should set a nonce- or hash-based CSP.
  2. Copy the CSP from the Strict CSP structure section and set it as a response header across your application.
  3. Refactor HTML templates and client-side code to remove patterns that are incompatible with CSP.
  4. Deploy your CSP.

You can use Lighthouse (v7.3.0 and higher with flag --preset=experimental) Best Practices audit throughout this process to check whether your site has a CSP, and whether it's strict enough to be effective against XSS.

Lighthouse
  report warning that no CSP is found in enforcement mode.
If your site doesn't have a CSP, Lighthouse shows this warning.

Step 1: Decide if you need a nonce- or hash-based CSP

Here's how the two types of strict CSP work:

Nonce-based CSP

With a nonce-based CSP, you generate a random number at runtime, include it in your CSP, and associate it with every script tag in your page. An attacker can't include or run a malicious script in your page, because they would need to guess the correct random number for that script. This only works if the number isn't guessable, and is newly generated at runtime for every response.

Use a nonce-based CSP for HTML pages rendered on the server. For these pages, you can create a new random number for every response.

Hash-based CSP

For a hash-based CSP, the hash of every inline script tag is added to the CSP. Each script has a different hash. An attacker can't include or run a malicious script in your page, because the hash of that script would need to be in your CSP for it to run.

Use a hash-based CSP for HTML pages served statically, or pages that need to be cached. For example, you can use a hash-based CSP for single-page web applications built with frameworks such as Angular, React or others, that are statically served without server-side rendering.

Step 2: Set a strict CSP and prepare your scripts

When setting a CSP, you have a few options:

  • Report-only mode (Content-Security-Policy-Report-Only) or enforcement mode (Content-Security-Policy). In report-only mode, the CSP won't block resources yet, so nothing on your site breaks, but you can see errors and get reports for anything that would have been blocked. Locally, when you're setting your CSP, this doesn't really matter, because both modes show you the errors in the browser console. If anything, enforcement mode can help you find resources your draft CSP blocks, because blocking a resource can make your page look broken. Report-only mode becomes most useful later in the process (see Step 5).
  • Header or HTML <meta> tag. For local development, a <meta> tag can be more convenient for tweaking your CSP and quickly seeing how it affects your site. However:
    • Later on, when deploying your CSP in production, we recommend setting it as an HTTP header.
    • If you want to set your CSP in report-only mode, you'll need to set it as a header, because CSP meta tags don't support report-only mode.

Set the following Content-Security-Policy HTTP response header in your application:

Content-Security-Policy:
  script
-src 'nonce-{RANDOM}' 'strict-dynamic';
 
object-src 'none';
 
base-uri 'none';

Generate a nonce for CSP

A nonce is a random number used only once per page load. A nonce-based CSP can only mitigate XSS if attackers can't guess the nonce value. A CSP nonce must be:

  • A cryptographically strong random value (ideally 128+ bits in length)
  • Newly generated for every response
  • Base64 encoded

Here are some examples of how to add a CSP nonce in server-side frameworks:

const app = express();

app
.get('/', function(request, response) {
 
// Generate a new random nonce value for every response.
 
const nonce = crypto.randomBytes(16).toString("base64");

 
// Set the strict nonce-based CSP response header
 
const csp = `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`;
  response
.set("Content-Security-Policy", csp);

 
// Every <script> tag in your application should set the `nonce` attribute to this value.
  response
.render(template, { nonce: nonce });
});

Add a nonce attribute to <script> elements

With a nonce-based CSP, every <script> element must have a nonce attribute that matches the random nonce value specified in the CSP header. All scripts can have the same nonce. The first step is to add these attributes to all scripts so the CSP allows them.

Set the following Content-Security-Policy HTTP response header in your application:

Content-Security-Policy:
  script
-src 'sha256-{HASHED_INLINE_SCRIPT}' 'strict-dynamic';
 
object-src 'none';
 
base-uri 'none';

For multiple inline scripts, the syntax is as follows: 'sha256-{HASHED_INLINE_SCRIPT_1}' 'sha256-{HASHED_INLINE_SCRIPT_2}'.

Load sourced scripts dynamically

You can load third-party scripts dynamically using an inline script.

An example of how to inline your scripts.
Allowed by CSP
<script>
 
var scripts = [ 'https://example.org/foo.js', 'https://example.org/bar.js'];

  scripts
.forEach(function(scriptUrl) {
   
var s = document.createElement('script');
    s
.src = scriptUrl;
    s
.async = false; // to preserve execution order
    document
.head.appendChild(s);
 
});
</script>
To let this script run, you must calculate the inline script's hash and add it to the CSP response header, replacing the {HASHED_INLINE_SCRIPT} placeholder. To reduce the amount of hashes, you can merge all inline scripts into a single script. To see this in action, refer to this example and its code.
Blocked by CSP
<script src="https://example.org/foo.js"></script>
<script src="https://example.org/bar.js"></script>
CSP blocks these scripts because they were not dynamically added and they have no integrity attribute which matches an allowed source.

Script loading considerations

The inline script example adds s.async = falseto ensure that foo executes before bar, even if bar loads first. In this snippet, s.async = false doesn't block the parser while the scripts load, because the scripts are added dynamically. The parser stops only while the scripts execute, as it would for async scripts. However, with this snippet, keep in mind:

  • One or both scripts might execute before the document has finished downloading. If you want the document to be ready by the time the scripts execute, wait for the DOMContentLoaded event before you append the scripts. If this causes a performance issue because the scripts don't start downloading early enough, use preload tags earlier on the page.
  • defer = true doesn't do anything. If you need that behaviour, run the script manually when it's needed.

Step 3: Refactor HTML templates and client-side code

Inline event handlers (such as onclick="…", onerror="…") and JavaScript URIs (<a href="javascript:…">) can be used to run scripts. This means an attacker who finds an XSS bug can inject this kind of HTML and execute malicious JavaScript. A nonce- or hash-based CSP prohibits the use of this kind of markup. If your site uses any of these patterns, you'll need to refactor them into safer alternatives.

If you enabled CSP in the previous step, you'll be able to see CSP violations in the console every time CSP blocks an incompatible pattern.

CSP violation reports in the Chrome developer console.
Console errors for blocked code.

In most cases, the fix is straightforward:

Refactor inline event handlers

Allowed by CSP
<span id="things">A thing.</span>
<script nonce="${nonce}">
  document
.getElementById('things').addEventListener('click', doThings);
</script>
CSP allows event handlers that are registered using JavaScript.
Blocked by CSP
<span onclick="doThings();">A thing.</span>
CSP blocks inline event handlers.

Refactor javascript: URIs

Allowed by CSP
<a id="foo">foo</a>
<script nonce="${nonce}">
  document
.getElementById('foo').addEventListener('click', linkClicked);
</script>
CSP allows event handlers that are registered using JavaScript.
Blocked by CSP
<a href="javascript:linkClicked()">foo</a>
CSP blocks javascript: URIs.

Remove eval() from your JavaScript

If your application uses eval() to convert JSON string serializations into JS objects, you should refactor such instances to JSON.parse(), which is also faster.

If you can't remove all uses of eval(), you can still set a strict nonce-based CSP, but you have to use the 'unsafe-eval' CSP keyword, which makes your policy slightly less secure.

You can find these and more examples of such refactoring in this strict CSP codelab:

Step 4 (Optional): Add fallbacks to support old browser versions

Browser Support

  • Chrome: 52.
  • Edge: 79.
  • Firefox: 52.
  • Safari: 15.4.

Source

If you need to support older browser versions:

  • Using strict-dynamic requires adding https: as a fallback for earlier versions of Safari. When you do this:
    • All browsers that support strict-dynamic ignore the https: fallback, so this won't reduce the strength of the policy.
    • In old browsers, externally sourced scripts can load only if they come from an HTTPS origin. This is less secure than a strict CSP, but it still prevents some common XSS causes like injections of javascript: URIs.
  • To ensure compatibility with very old browser versions (4+ years), you can add unsafe-inline as a fallback. All recent browsers ignore unsafe-inline if a CSP nonce or hash is present.
Content-Security-Policy:
  script
-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline';
 
object-src 'none';
 
base-uri 'none';

Step 5: Deploy your CSP

After confirming that your CSP doesn't block any legitimate scripts in your local development environment, you can deploy your CSP to staging, then to your production environment:

  1. (Optional) Deploy your CSP in report-only mode using the Content-Security-Policy-Report-Only header. Report-only mode is handy to test a potentially breaking change like a new CSP in production before you start enforcing CSP restrictions. In report-only mode, your CSP doesn't affect your app's behavior, but the browser still generates console errors and violation reports when it encounters patterns incompatible with your CSP, so you can see what would have broken for your end users. For more information, see Reporting API.
  2. When you're confident that your CSP won't break your site for your end-users, deploy your CSP using the Content-Security-Policy response header. We recommend setting your CSP using an HTTP header server-side because it's more secure than a <meta> tag. After you complete this step, your CSP starts protecting your app from XSS.

Limitations

A strict CSP generally provides a strong added layer of security that helps to mitigate XSS. In most cases, CSP reduces the attack surface significantly, by rejecting dangerous patterns like javascript: URIs. However, based on the type of CSP you're using (nonces, hashes, with or without 'strict-dynamic'), there are cases where CSP doesn't protect your app as well:

  • If you nonce a script, but there's an injection directly into the body or the src parameter of that <script> element.
  • If there are injections into the locations of dynamically created scripts (document.createElement('script')), including into any library functions that create script DOM nodes based on the values of their arguments. This includes some common APIs such as jQuery's .html(), as well as .get() and .post() in jQuery < 3.0.
  • If there are template injections in old AngularJS applications. An attacker that can inject into an AngularJS template can use it to execute arbitrary JavaScript.
  • If the policy contains 'unsafe-eval', injections into eval(), setTimeout(), and a few other rarely used APIs.

Developers and security engineers should pay particular attention to such patterns during code reviews and security audits. You can find more details on these cases in Content Security Policy: A Successful Mess Between Hardening and Mitigation.

Further reading