CSP Testing Using Cypress

How to verify Content-Security-Policy (CSP) stops cross-site-scripting (XSS) attacks.

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 posted message

The initial code is really simple; we take the textarea contents and add a new list item element.

public/app.js
1
2
3
4
5
6
7
8
send$.addEventListener('click', () => {
const message = message$.value
const li = document.createElement('li')
li.innerText = message
messages$.appendChild(li)
message$.value = ''
send$.setAttribute('disabled', 'disabled')
})

We can confirm the messages are added by writing a Cypress end-to-end test

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// https://github.com/bahmutov/cypress-slow-down
import { slowCypressDown } from 'cypress-slow-down'
// slow down each command by 700ms
// to make the demo videos easier to understand
slowCypressDown(700)

it('adds a new message', () => {
cy.visit('/')
cy.contains('button', 'Send').should('be.disabled')
cy.get('#message').type('Hello')
cy.contains('button', 'Send').click()
cy.get('#messages li').should('have.length', 1).and('have.text', 'Hello')
cy.get('#message').should('have.value', '')
cy.contains('button', 'Send').should('be.disabled')
})

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.

public/app.js
1
2
3
4
5
6
7
8
send$.addEventListener('click', () => {
const message = message$.value
const li = document.createElement('li')
li.innerHTML = message
messages$.appendChild(li)
message$.value = ''
send$.setAttribute('disabled', 'disabled')
})

Let's test adding bold messages.

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
it('adds a bold message', () => {
cy.visit('/')
cy.get('#message').type('<b>Important</b>')
cy.contains('button', 'Send').click()
cy.get('#messages li b')
.should('have.length', 1)
.and('have.text', 'Important')
})

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.

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
it('injecting <script> tag does not work', () => {
cy.on('window:load', (win) => cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message').type('Hello<script>console.log(`hacked`)</script>')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.get('@log').should('not.have.been.called')
})

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.

The appended script tag inside LI does not execute

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:

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
it('injects XSS via img onerror attribute', () => {
cy.on('window:load', (win) => cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message').type('Hello<img src="" onerror="console.log(`hacked`)" />')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.get('@log').should('have.been.calledWith', 'hacked')
})

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

public/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<head>
<link rel="stylesheet" href="./app.css" />
</head>
<body>
<h1>Chat</h1>
<textarea
id="message"
rows="4"
cols="50"
placeholder="Say something to me"
></textarea>
<button id="send" disabled>Send</button>
<ol id="messages"></ol>
<script src="./app.js"></script>
</body>

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

index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const express = require('express')
const helmet = require('helmet')
const app = express()
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
// ughh, have to allow unsafe inline styles to be added by Cypress
// https://github.com/cypress-io/cypress/issues/21374
styleSrc: ["'self'", "'unsafe-inline'"],
reportUri: ['/security-attacks'],
},
},
}),
)
app.use(express.static('public'))

When we load our site in the browser, we see the CSP header

The server returns Content-Security-Policy header with the homepage

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.

cypress.config.js
1
2
3
4
5
6
7
8
9
10
const { defineConfig } = require('cypress')

module.exports = defineConfig({
// https://on.cypress.io/experiments
// https://github.com/cypress-io/cypress/issues/1030
experimentalCspAllowList: ['default-src', 'script-src'],
e2e: {
baseUrl: 'http://localhost:3003',
},
})

We can verify the CSP header is present in the returned page using Cypress cy.request command (an API test!!!!)

cypress/e2e/with-csp.cy.js
1
2
3
4
5
6
7
8
it('serves Content-Security-Policy header', () => {
cy.request('/')
.its('headers')
.should('have.property', 'content-security-policy')
// confirm parts of the CSP directive
.should('include', "default-src 'self'")
.and('include', 'report-uri /security-attacks')
})

The test confirms the CSP header is present

If the CSP header is NOT present, then the XSS attack can succeed

cypress/e2e/with-csp.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
it('can strip CSP and allow injections', () => {
cy.intercept('GET', '/', (req) =>
req.continue((reply) => {
delete reply.headers['content-security-policy']
}),
)
cy.on('window:load', (win) => cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message').type('Hello<img src="" onerror="console.log(`hacked`)" />')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')
cy.get('@log').should('have.been.calledWith', 'hacked')
})

XSS attack works if we delete the CSP header

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.

CSP policy tells the browser to not execute inline scripts

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
2
3
4
5
6
7
8
9
10
11
12
13
it('stops XSS and reports CSP violations', () => {
cy.intercept('/security-attacks', {}).as('cspAttacks')

cy.on('window:load', (win) => cy.stub(win.console, 'log').as('log'))
cy.visit('/')
cy.get('#message').type('Hello<img src="" onerror="console.log(`hacked`)" />')
cy.contains('button', 'Send').click()
cy.contains('#messages li', 'Hello')

cy.log('**XSS stopped and reported**')
cy.wait('@cspAttacks').its('request.body').should('include', 'blocked')
cy.get('@log').should('not.be.called')
})

Testing CSP policy against XSS attacks

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