I have written about using Content-Security-Policy (usually shortened to just CSP) to protect your website from cross-site scripting attacks. Using CSP you can restrict the sources of JavaScript allowed to run on the page, especially disabling the inline JavaScript - and your users are much safer!
But is your protection actually working? Can you verify that the CSP header is set correctly? Can you write a security test that tries to inject a script and gets stopped? Let me show you how to write such test using Cypress. It is almost a perfect way to sprinkle some security testing into your functional end-to-end tests. I am saying almost, because this approach almost works.
🧭 You can find the source code for this blog post in the repository bahmutov/test-csp.
IMPORTANT
This feature works in Cypress v12.15+, read the blog post CSP Testing Using Cypress.
The app
Let's first create an application to be attacked using script injection. It is a page with an inline script tag
1 | <body> |
We can serve the application using any static web server, for example using Express.js
1 | const express = require('express') |
When we load this page in the browser we see the inline script executing
If we see this popup on the page, that's bad. That means an attacker can also somehow inject something (potentially) that would also run, stealing another user's private and sensitive information. Let's stop all inline scripts from running.
Content security policy
Let's lock our web application by serving it with Content-Security-Policy
header by using Helmet module. Just add it to our application and restart the server.
1 | const express = require('express') |
When we see the page headers now we will see a slew of response headers making the page a lot more secure.
And the best result - the alert
message box does not pop up anymore. Instead we see an angry message in the browser's console
Great, our Content-Security-Policy
is protecting our site's users by blocking all inline scripts.
Reporting violations
The Content-Security-Policy
stops the scripts that violate the security policy from running. The browser also prints the security error to the console. We can also report any security errors to an external server using /report-uri
option in the policy. For this, we need to actually provide our own CSP policy plus reportUri
address to send all attack events.
1 | app.use( |
When loading the page we can see the attack reported to the (non-existent) endpoint http://localhost:3003/security-attacks
.
We can inspect the request to see the object sent by the browser with the attack's details
Content-Security-Policy and Cypress
Wouldn't it be cool if we could listen to the POST /security-attacks
calls from the browser and catch them from Cypress tests to check if the CSP is set up correctly? Using the new cy.route2 API we could rewrite the HTML document to insert script tags from different domains trying to see if we allow executing 3rd party code - and we expect these security attacks to be reported. We could insert script tags directly into the DOM using JS code, again expecting these security violations to be reported.
Well, there is a small problem here. Let's try loading our page in the Cypress browser.
Notice that the unsafe inline script has executed - you can see the alert
message in the Command Log. Let's inspect the localhost
document and list its headers. This is what the browser sees when Cypress visits the page.
If you compare these headers to the full set of headers on the document in the "normal" browser that I have shown at the start of the blog, you will notice that some security headers are automatically stripped by Cypress. This is done to allow Cypress to load the site in an iframe (thus stripping X-Frame-Options
header) and to inject a tiny script at the start of the page (thus it needs to remove the Content-Security-Policy
header)
This tiny script that sets document.domain = 'localhost';
is the key to how Cypress works - it allows the JavaScript from the test domain (usually something like localhost:<random port>
) to access document from any other domain loaded in an iframe. But to inject this script we need to remove the CSP header, since it would normally prevent unsafe inline scripts.
Attempt 1 - move CSP header into meta
There is a nice little workaround we can perform to still use CSP protection, yet allow Cypress to load the page. The Content-Security-Policy
can be set either via a response header (recommended), or via <meta ...>
HTML tag (not recommended). In our test, we can copy the header into the document's <HEAD>
!
1 | it('loads', () => { |
The above test spies on the /
request, looks at the server's response, and copies the CSP header into the <HEAD><meta ...>
tag. Let's look at the result:
We have some wins, and we also have a step back.
First, the wins:
- The content security policy works. The
<script>alert(42)</script>
inline block did NOT run. We see an error report and noalert
command in the Command Log. - Cypress tests still work, even though the little script was injected. Turns out, when CSP is set using a
<meta>
tag, it applies to the content after the<meta>
tag itself. Thus Cypress' own inline tag which comes first is fine. What a lucky break.
Second, the loss:
- as you can see in the browser's console, the
/report-uri
directive does NOT work when the CSP is set via<META>
tag unfortunately (for security reasons). Thus my hope of intercepting the triggeredPOST /security-attacks
calls has been dashed.
Attempt 2 - Content-Security-Policy-Report-Only
In the CSP world, there is the second header we could use to NOT block the unsafe scripts, but to report them only. This header is a nice real world migration strategy that allows you to set the CSP and see if it blocks any of the users before turning it on. We could grab the CSP header and copy it to CSP-Report-Only
header when loading the document.
1 | return req.reply((res) => { |
Unfortunately, in Cypress v5.3.0 we started removing content-security-policy-report-only
header to be consistent. Sigh, I am thwarted again.
Future solution (I hope)
So in order for Cypress to work without stripping Content-Security-Policy
we should keep the original CSP policy plus inject a permission to load just our Cypress script. This could be done by adding to the list of allowed script sources one more script with a random nonce
value.
1 | // allow Cypress script injection |
later on when injecting Cypress script into <HEAD>
element inject it with nonce attribute
1 | <script nonce="<abc..random>" type="text/javascript"> |
This is why I have opened #1030 a short time (well, 3 years!) ago. One day I will get to it, and then the security testing against cross-site scripting attacks will become a Cypress feature.