In this article, we'll learn (almost) everything about content security policies (CSP). Even if you've never heard of CSP before, and have no idea what it is, I promise that by the end of the article you'll have a solid understanding and a concrete, step-by-step plan to secure your website and apps from Cross-Site Scripting (XSS) Injection Attacks.
First, a disclaimer. I am not a security expert by any shape or form. To be honest, I had no idea what the term 'Content Security Policy' meant until a few days ago. But last week, I got a chance to implement CSPs at work, and found it fascinating. So I spent the last few days (including the weekend), reading up everything I could get my hands on, and wanted to write down everything I learned in this article before I forgot it.
So, please do not read this article as if it was written by an expert. If you find any mistakes in this article, please leave a helpful comment or email me so I could improve this article for myself and for everyone.
With that out of the way, here's the plan for this guide.
- I strongly believe that you won't truly understand the solution if you don't know the problem. So we'll start with a brief exploration into the XSS attack, the security vulnerability CSP is trying to protect us from.
- After that, we'll cover the basics of content security policy, including what it is, how it works, and why it's so important. I'll also show you the practical changes and refactoring you need to make to implement CSP in your codebase.
- You have to make some effort to refactor your code to follow CSP best practices. If you implement a strict policy without proper planning, the website might break, as CSPs prevent all inline and external scripts and styles. We'll cover the best practices, such as reporting before enforcing and making exceptions with
nonce
attribute, as well as provide a step-by-step plan for a smooth transition. - We'll wrap up with a reference of all the important CSP-related commands, headers, directives, and their values.
Sounds good? Let's get started.
The Problem: Cross-Site Script Injection
TL;DR: Most web applications fetch data from the database, insert it into an HTML template and send the final HTML to the browser. An attacker injects bad JavaScript into the database by submitting it via a form on the website. On the next request, the server sends the bad script to the browser, which executes that script thinking it came from your server. The script does bad things.
Let's imagine for a moment that you're a hacker who wants to steal the credit card numbers of an e-commerce website's users.
How'd you go about this?
One thing you could do is write some JavaScript that records each keystroke on the payment page as the users enter their credit card details.
Since browsers execute any JavaScript on the page, and JavaScript can read, write, or modify any part of a website, if you can find a way to insert your bad, bad JavaScript code on the e-commerce website, then the users' credit card numbers aren't safe anymore.
But for the browser to execute this JavaScript, it needs to be part of the website's code.
How can you get this bad JavaScript code on the e-commerce website, so that the user's browser executes it while they are making the payment?
You notice that there's a form on this e-commerce website that lets anyone add reviews about a product. So you create an account, and instead of a real review, you type the following code in the textarea.
After hitting the submit button, you see the alert window pop up, showing you the message you just entered.
How did that happen?
Well, after you submitted your review, the browser sent that fake review (which instead is JavaScript) to the e-commerce website's backend server, which saved it in the database as plain-text.
Next, in the response for that request, the backend redirected you to the same product page, which refreshed and fetched the same fake review from the backend. The backend script promptly fetched the stored review from the database and sent it to the browser.
Upon receiving the HTML containing the fake review, the browser thought it was a script that you wanted to execute, and promptly executed it, showing you the alert box.
What's more, since the reviews are public and visible to every user of the website, the same HTML containing the injected script is sent to every other user, greeting them with the same alert!!
Now that you know that the website is vulnerable to XSS attacks, you write an elaborate script that records the users' keystrokes and makes a fetch request to post all that data to your other website, which saves all this information for you to exploit later. Or you can hijack the user's session entirely by accessing their session information, which lets you login as that user remotely.
Then you insert it in the database in the same way as the alert script, by adding another fake review on the product page.
Congratulations, you've successfully implemented a cross-site scripting attack!
XSS is one of the most common security vulnerabilities. An attacker can try to inject this script anywhere they can submit a form, such as the title or body of an article, product review, or comments. If the website doesn't prevent against XSS, they can get full access to the data on the website.
Now think from the e-commerce website's developer's point of view. How do you mitigate this attack?
Solution One: Escape HTML Characters
A simple fix to prevent cross-site scripting attacks, is to take all user-submitted text, and replace any special control characters in it like <
, >
, etc. with their corresponding entity encoding. This is called character escaping.
For example, the HTML
<script>alert('hello world')</script>
is converted to
<script>alert('hello world')</script>
When the browser sees the escaped content, it recognizes and renders it just like the regular text, but it won't treat it like an HTML tag. In other words, it won't execute the code inside the escaped <script>
tags, which is what we want.
In general, you need to escape the following characters in the context of HTML, so they don't add any special behavior to the markup.
- less than symbol (<) with
<
- greater than symbol (>) with
>
- double quotes (") with
"
- single quote (’) with
'
- ampersand (&) with
&
Often, you don't need to worry about escaping special characters, as most modern web frameworks handle it for you. For example, in Ruby on Rails, the ERB templating engine escapes dynamic content by default.
<div>
<%= review %>
</div>
If you do want to write raw, unescaped HTML, you have to instruct it explicitly to do so, e.g. using the raw
function, or the <%== variable %>
ERB format (note the double ==
).
In many cases, ensuring the user-submitted HTML content is properly escaped takes you a long way towards protecting your users from XSS attacks. If you need to go all the way, you've to combine escaping with a strict Content Security Policy.
Solution Two: Content Security Policy
The basic idea behind CSP is to block inline script execution and provide the list of allowed sources of trusted content (scripts, stylesheets, fonts, plugins, etc.) to the browser. Even if an attacker gets to inject their bad script, the browser won't execute it since CSP prevents inline script execution.
The root cause of XSS attacks is the browser's ability to execute any and all inline JavaScript included in the HTML. What if there was a way to tell the browser to not execute any inline JavaScript, as well as any scripts loaded from external domains?
<script>
tags. That would only leave the safe JavaScript code included with the <script src="my-domain.com/safe.js">
, i.e. with the src
attribute.You can achieve this using the Content-Security-Policy
header on the HTTP response, which dictates the security policy for the content. In its essence, this header does two things:
- Instructs the browser to block all inline script execution
- Provide a list of safe domains from where to load external resources, preventing all other sources.
Although most often it's used for scripts, it also controls other resources like stylesheets, fonts, plugins, etc.
Implementing a strict CSP prevents browsers from fetching scripts from any other locations. This makes it really, really hard for a bad actor to inject bad JavaScript code in your website. Even if they inject it somehow, the browser won't execute it since it's inline.
All modern browsers support CSP. Since cross-site scripting attacks happen due to unwanted JavaScript execution on the browser, locking down all external and inline JavaScript goes a long way towards preventing XSS attacks.
When your website implements a strict CSP, an attacker won't be able to run any bad JavaScript on the site.
So, how does it work?
Here's an example of a standard CSP header.
Content-Security-Policy: script-src 'self' https://safe-external-site.com; style-src 'self'
In above header the term script-src
and style-src
are directives that specify valid sources for JavaScript and stylesheets, respectively.
Both directives tell the browser not to execute any inline JavaScript or stylesheet. In addition,
- The script policy tells the browser to only run scripts fetched from the same domain (
self
) or from the domainsafe-external-site.com
. If the website is hosted at mysite.com, the browser will prevent all scripts except the ones loaded from mysite.com and safe-external-site.com, since we explicitly allowed it. - The style policy tells it to only allow stylesheets from the same domain. No external stylesheets will be permitted.
The browser will execute JavaScript only if it's imported from your own domain via asrc
attribute in the<script>
element.
Instead of using an HTTP header, you could also provide the policy in the <meta>
tag, which lives under the <head>
element.
Here's a content security policy provided via the <meta>
tag.
<meta http-equiv="Content-Security-Policy" content="script-src 'self' https://safe-external-site.com">
`unsafe-inline` lets you bypass the CSP
If, for some reason, you have to permit inline JavaScript or styles, use the unsafe-inline
value on the corresponding directive. For example, the following policy allows all inline JavaScript, and hence defeats the basic purpose of having a CSP.
Content-Security-Policy: script-src 'self' 'unsafe-inline' https://safe-external-site.com
If you do have to use inline scripts, using the unsafe-inline
value is not recommended. As we'll see in the next section, there's a much better solution if you absolutely need to execute inline script.
Important CSP Directives and Values
So far, we've seen the script-src
and style-src
, which specify valid sources for scripts and stylesheets. Here're a few other important ones. For a complete list, check out the MDN documentation for directives.
default-src
: Defines the default policy for fetching all resources. Whenever a more specific policy is absent, the browser will fallback to thedefault-src
policy directive. Example:default-src 'self' trusted-domain.com
img-src
: Defines valid sources for images, e.g.img-src 'self' img.mydomain.com
font-src
: Defines valid sources for font resources.object-src
: Defines valid sources for plugins and external content, like <object> or <embed> elements.media-src
: Defines valid sources for audio and video.
In addition to unsafe-inline
, the source list also accepts the following values. Again, this is not a comprehensive list, refer to the MDN docs for more values.
'none'
: Match nothing.'self'
: Match the current host domain (same origin, i.e. host and port). Do not match subdomains, though.'unsafe-inline'
: Allow inline JavaScript and CSS.'unsafe-eval'
: Allow dynamic text to JavaScript evaluations.domain.example.com
: Allow loading resource from the specified domain. To match any subdomain, use the*
wildcard, e.g. *.example.comhttps:
orws:
: Allow loading resources only over HTTPS or WebSocket.nonce-{token}
: Allow an inline script or CSS containing the same nonce attribute value.
Note: The quotes ''
are required for the first four values.
When You Do Need Inline Scripts and Styles, Use nonce Attribute
Thenonce
attribute is useful to allow specific inline script or style elements. It helps you to avoid using the CSPunsafe-inline
directive, which allows all inline code.
The term nonce
refers to a word or a phrase that's used only once.
In the context of CSP, nonce
is an attribute on the <script>
and <style>
tags that lets you allow specific inline script
and style
elements, while still blocking all other inline scripts and styles. This lets you avoid the use of unsafe-inline
directive, which allows all inline executions and is not safe.
Using the nonce
attribute lets you keep the essential benefits of CSP while still allowing specific inline executions that you absolutely do need.
How does it work?
- For every request, the server creates a random base64-encoded string that cannot be guessed. This is the nonce value, e.g.
dGhpcyBpcyBhIG5v==
- The server inserts this nonce value in all the inline script and style elements that you want to allow, as the value for the
nonce
attribute.
<script nonce="dGhpcyBpcyBhIG5v==">.. ..</script>
<style nonce="dGhpcyBpcyBhIG5v==">.. ..</style>
- The server also inserts the same nonce value in the
Content-Security-Policy
header forscript-src
andstyle-src
directives.
Content-Security-Policy: script-src 'nonce-dGhpcyBpcyBhIG5v=='; style-src 'nonce-dGhpcyBpcyBhIG5v=='
nonce
attribute set to this value.Since the attacker can't guess the token, they can't inject bad JavaScript.
To summarize, the presence of nonce
attribute on specific script and style elements instructs the browser to allow inline execution in those elements, while still blocking all other inline scripts and styles that don't have the nonce
attribute.
It tells the browser that these elements were not injected by the hacker (since they couldn't guess the nonce value), but were intentionally inserted by the server, so they're safe to execute.
Adopting CSP: Start by Reporting, but Not Enforcing CSP Violations
Use theContent-Security-Policy-Report-Only
header to iteratively work on your content security policy. You observe how your site behaves, watching for violation reports, then choose the desired policy enforced by theContent-Security-Policy
header. - MDN
Implementing a strict CSP policy sounds great in theory, but the reality is not as simple. Many legacy websites abundantly use inline JavaScript or scripts hosted on external domains.
What if you have a huge legacy codebase that's littered with inline scripts within PHP/HTML files everywhere? On top of that, you have no idea how many places you're using inline and external scripts. What to do?
Since CSP prevents all inline scripts and blocks all external sources by default, you'll either have to remove/refactor all inline JavaScript and external sources or explicitly allow them in the policy.
Otherwise, if you immediately enable a strict policy preventing all inline or external scripts and stylesheets, you're in trouble. the browser will neither load external scripts/styles nor execute inline scripts, and the websites will crash and burn for your users.
You don't want that.
How should you go about it, then? You know it's going to take a long time to refactor the code to follow the best practices. And how do you find out where are you violating CSP?
There's an elegant solution for this dilemma. Instead of immediately setting a strict CSP and risking any crashes in production, you can use the report-only mode.
Using the Cotent-Security-Policy-Report-Only
header, you can tell the browser to only report, but not enforce the policies.
So the browser will still load the external scripts and execute any inline JavaScript, but it will also report all violations to an endpoint defined in the policy using the report-to
directive.
Content-Security-Policy-Report-Only: script-src 'self'; report-to https://mysite.com/csp-violations
The endpoint can be a route in your application where you write the code to parse the violation data and save it in the database, or you can use a third-party service like report-uri.com
which shows it nicely in a table in the browser, with support for filtering and ordering.
In any case, you can see all the places where you need to make a change in the code (or in the policy) to follow the security policy.
Pretty cool.
How to Implement a Strict CSP
If you're still reading, and convinced that you need to implement a strict content security policy for your website or app, you'll need to add the CSP header, change your HTML templates to add nonce
attributes, explicitly allow external domains for the content sources, and test everything works and nothing's broken for your users.
Here's a step-by-step plan to start implementing content security policy on your website.
- Report, don't enforce. Start with the
Content-Security-Policy-Report-Only
header to iteratively work on your policy. Keep an eye on violation reports, and fix issues as they arise until there're no more violation reports. - Refactor your code to remove inline JavaScript and move it to JavaScript files loaded with the
src
attribute on the<script>
element. Refactor the HTML containing inline event handlers likeonclick
andjavascript:
URIs. - Make exceptions. For the places where you have to execute inline scripts or use inline CSS, generate a
nonce
token on the server, put it on thenonce
attribute for the script and style elements, and pass it to the policy. If you need to load external scripts and styles, add those domains to the policy. - Enforce the policy. Set the
Content-Security-Policy
header for your site. Most likely, your backend framework supports this already, either as part of configuration (Rails) or with a middleware (Laravel). If not, just set one manually. - Test thoroughly. Before deploying to production, let your testing team know about the CSP and ask them to watch out any CSP violation reports in the console. Finally, watch out for CSP violations in production on the users' browser in the endpoint logs or on the site report-uri.com.
Here're a few examples of the types of changes you'll be making.
- Add nonces to
<script>
and<style>
elements
<script src="my_script.js"></script>
<script>alert('hello world')</script>
<style>
body {
background-color: green;
}
</style>
becomes
<script nonce="token" src="my_script.js"></script>
<script nonce="token">alert('hello')</script>
<style nonce="token">
body {
background-color: green;
}
</style>
- Refactor inline JavaScript handlers and URIs
Often, legacy websites can contain event handlers using the onclick
or onerror
callbacks. These are vulnerable to XSS attacks. Refactor this so the handlers are added from JavaScript block. Ideally, you should move it to a separate JavaScript file.
<script>
function handle() {
// click handler code
}
</script>
<button onclick="handle();">Click Me</button>
becomes
<button id="submit-btn">Click Me</button>
<script nonce="token">
document.addEventListener('DOMContentLoaded', function () {
document.getElementById('submit-btn')
.addEventListener('click', () => {
// click handler code
});
});
</script>
It's important to remember that the policy is defined per page. You need to send this header on every HTTP response that you want to protect. That said, the framework should take care of this for you. For example, Rails lets you define it for the whole application as well as on the controller-action level.
Conclusion
In cross-site scripting injection attack, an attacker tries to inject bad code in the HTML of your website, tricking the user's browser into executing that code. To prevent this, you need to escape the user-submitted data you receive from forms. In addition, you need to set a strict content security policy instructing browsers to prevent all inline script execution and only loading external scripts from pre-approved domains.
Once you start escaping user-submitted content and implement a strict CSP, you're well on your way to protecting your users. It's very difficult for an hacker to inject any random script in the page with a strict and tight security policy.
That said, having a strict CSP doesn't fully guarantee that your site is full-proof from attacks. Combine it with other security best-practices and a manual security review to further decrease the chances of an attack.
Parting words: If you're running a static blog without any user-generated content, you probably don't need to worry about having a strict CSP. If on the other hand, you're running an application that manages sensitive, personally identifiable information in industries like healthcare and finance, it's crucial to have a strict content security policy.
References: To Learn More
- W3C Specification of Content Security Policy
- Content Security Policy overview on MDN
- CSP Report-Only Header and report-uri.com
- Web.dev guide on Content Security Policy
- CSP Quick Reference Guide
To learn how to implement Content Security Policy in Ruby on Rails applications, check out this post.
If you liked this article, check out my post on Cross-Site Request Forgery attack.
Then, learn about how Rails prevents CSRF attacks with authenticity tokens.
That's a wrap. I hope you liked this article and you learned something new. If you're new to the blog, check out the full archive to see all the posts I've written so far or the favorites page for the most popular articles on this blog.
As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I look forward to hearing from you.
If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.