Cross-site-scripting attacks happen when the text data from one user executes as a script when viewed by another user. Imagine posting a message on a chat board which when any other user views it steals their cookies and other sensitive information. Here is an example I have prepared in the bahmutov/cypress-csp-example repo.
Message app
The user can enter the message and see it on the page.
The initial code is really simple; we take the textarea contents and add a new list item element.
1 | send$.addEventListener('click', () => { |
We can confirm the messages are added by writing a Cypress end-to-end test
1 | // https://github.com/bahmutov/cypress-slow-down |
Tip: I am using my plugin cypress-slow-down to add small pauses after each Cypress command, otherwise the video goes way too quickly.
Allow formatted messages
The app looks great. Except people ask to be able to format messages. Let's allow bold and italics using HTML tags <b>
and <i>
. Ughh, time is short, so let's use HTML for this! We modify our app to use .innerHTML =
instead of .innerText =
, quickly and powerful.
1 | send$.addEventListener('click', () => { |
Let's test adding bold messages.
1 | it('adds a bold message', () => { |
Script inject
Ok, we heard about hackers, so let's make sure these bad bad no-goodniks cannot simply add script code to our list of messages.
1 | it('injecting <script> tag does not work', () => { |
All good, the console.log
is NOT running at all, the browser does not treat the appended <script>
tag as code and does not execute it.
We are safe and sound.
The 3am wake up call
Our nightmares are interrupted by a wake up call. Someone stole all user information by injecting JavaScript into their messages. How did they do it? Turns out there are a lot of places where the browser WILL execute JavaScript. Styles, attributes, CSS import directives... Here is what the hackers did:
1 | it('injects XSS via img onerror attribute', () => { |
Ughh, time to move to Canada and grow our own vegetables.
Content-Security-Policy
Our hackers entered text data and the browser simply executed it as code. In an ideal world, the browser would only execute code from our ./app.js
script, or at least from our website, as listed in the public/index.html
1 | <head> |
We want to disable all inline scripts and all inline JavaScript attributes, this would stop script injections. This is where the ](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) comes into play. We can tell the browser the list of allowed places to load JavaScript (and other things) from. For example, we could allow scripts to load from the domain itself (called "self") and from specific CDN servers and from nowhere else. Here is how to specify this using helmet Node.js plugin for Express.js
1 | const express = require('express') |
When we load our site in the browser, we see the CSP header
By default, Cypress strips this header to be able to inject its own inline script directive. But we can tell Cypress to preserve it and instead modify it to inject each specific script using a nonce.
1 | const { defineConfig } = require('cypress') |
We can verify the CSP header is present in the returned page using Cypress cy.request
command (an API test!!!!)
1 | it('serves Content-Security-Policy header', () => { |
If the CSP header is NOT present, then the XSS attack can succeed
1 | it('can strip CSP and allow injections', () => { |
CSP stops the XSS attack
What happens if the CSP policy header is present and the hacker tries to use the <img onerror="..." />
trick? Let's try in the regular browser.
Our CSP policy successfully prevented the browser from executing the script added by the hacker. Not only that, the XSS attack was reported as a CSP policy violation to the /security-attacks
endpoint on our server. We do not even have this endpoint yet, but that's ok - stopping the attack is the top priority. Let's verify the CSP policy works using a Cypress E2E test. We can even intercept the POST /security-attacks
network call and verify what the browser sends is correct.
1 | it('stops XSS and reports CSP violations', () => { |
Nice!
Note: if you are thinking "I will sanitize the user input to only allow <b>
and <i>
tags to be safe" - it is a very hard problem. CSP and whitelisting script sources is a much safer approach to security. At least use a hardened library like cure53/DOMPurify to sanitize the user input before inserting it in to the page.
Learn more
- 🖥️ my presentation End-To-End Test Your Web Security
- Content Security Policy (CSP)
- 📝 Disable inline JavaScript for security
- 📝 Testing Content-Security-Policy using Cypress ... Almost
- 🎓 Want to learn more about network testing with
cy.intercept
andcy.request
commands? Take my Cypress Network Testing Exercises course. - 🎓 I have a course about Cypress plugins like
cypress-slow-down
.