Cypress CSRF Form Testing

How to make API requests to endpoints protected against cross-site request forgery.

Imagine we have a form that we submit from the browser. What prevents some hackers from spamming the same API endpoint submitting 100s of POST requests? A security technique called CSRF is used to make sure only the requests "pre-authorized" by our web server go through. All other API requests are quickly rejected.

🎓 This blog post is based on several bonus lessons in my course Cypress Network Testing Exercises. If you need an example application to run these tests against, use the public repo bahmutov/fastify-example. You can also learn a lot about Cypress cy.intercept, cy.request, and related commands by coding up exercise specs in bahmutov/fastify-example-tests.

CSRF form

In the first implementation of CSRF protection, when we request the form page, the server generates a random CSRF token string and injects it into the page as <input type="hidden" value="...token..." />. Every page request gets a different token and the tokens are stored by the web server in some kind of storage.

HTML form with the hidden CSRF input field

When the page is submitted, the "regular" input fields plus the hidden input field with the token are sent to the web server. Let's write a Cypress test to confirm it. We can spy on the POST submission network call to get its request body.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// https://github.com/anonrig/fast-querystring#readme
const qs = require('fast-querystring')

describe('CSRF protection', () => {
it('sends the CSRF token with the submitted form', () => {
cy.visit('/csrf-form.html')
// confirm the form has a hidden input field with CSRF token
// the value should be a long-ish string
cy.get('form input[type=hidden][name=csrf]')
.should('have.attr', 'value')
.should('be.a', 'string')
.and('have.length.greaterThan', 10)

cy.get('[name=username]').type('Joe')
cy.intercept('POST', '/submit-csrf-form').as('submit')
cy.contains('button', 'Register').click()

// confirm the new page is at url "/submit-csrf-form"
cy.location('pathname').should(
'equal',
'/submit-csrf-form',
)
cy.contains('[data-cy=username]', 'Joe')
cy.wait('@submit')
.its('request.body')
.then(console.log)
.should('be.a', 'string')
.then(qs.parse)
.should('have.keys', ['username', 'csrf'])
.and('have.property', 'username', 'Joe')
})
})

The test confirms the CSRF field is sent with the form

The server checks the CSRF token

The first thing the server does it check if the CSRF token is present.

server.js
1
2
3
4
5
6
7
8
9
10
11
12
fastify.post('/submit-csrf-form', (request, reply) => {
const { username, csrf } = request.body
if (!csrf) {
const message = 'Bad or missing CSRF value'
return reply.code(403, message).type('text/html').send(stripIndent`
<body data-cy="error">
${message}
</body>
`)
}
// continue
})

Let's confirm that the form needs the csrf input field to be accepted. Let's write a test that removes the hidden input and tries to submit it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
it('rejects the form with missing CSRF token', () => {
cy.visit('/csrf-form.html')
// remove the CSRF input from the page
cy.get('form input[type=hidden][name=csrf]')
.should('exist')
.invoke('remove')

cy.get('[name=username]').type('Joe')
cy.intercept('POST', '/submit-csrf-form').as('submit')
cy.contains('button', 'Register').click()
cy.location('pathname').should(
'equal',
'/submit-csrf-form',
)
// confirm the network call aliased "submit"
// received error response code 403 from the server
cy.wait('@submit')
.its('response.statusCode')
.should('equal', 403)
})

The test confirms the CSRF field must be present

The server also checks that it receives the CSRF token it recognizes. In my simply implementation, all issues tokens are stored in an object and can be received only once:

server.js
1
2
3
4
5
6
7
8
9
10
11
12
if (!csrfTokens[csrf]) {
const message = 'Invalid CSRF value'
return reply.code(403, message).type('text/html').send(stripIndent`
<body data-cy="error">
${message}
</body>
`)
}
// valid CSRF token
// remove the used up CSRF token to prevent multiple submissions
delete csrfTokens[csrf]
// send the page

Ok, time to write a test. Instead of removing the CSRF form input field, let's set its value to something random.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it('rejects the form with incorrect CSRF token', () => {
cy.visit('/csrf-form.html')
// confirm the form has a hidden input field with CSRF token
// and change its value to something else
cy.get('form input[type=hidden][name=csrf]')
.should('exist')
.invoke('val', 'abc123')

cy.get('[name=username]').type('Joe')
cy.intercept('POST', '/submit-csrf-form').as('submit')
cy.contains('button', 'Register').click()
cy.location('pathname').should(
'equal',
'/submit-csrf-form',
)
// confirm the network call aliased "submit"
// received error response code 403 from the server
cy.wait('@submit')
.its('response.statusCode')
.should('equal', 403)
})

The test confirms the CSRF token cannot be some random value

Super.

Making API requests

Let's say we want to speed up our tests by bypassing the user interface and making direct API calls using cy.request command. Unfortunately, CSRF prevents us from making direct network requests, and we can prove it.

1
2
3
4
5
6
7
8
9
10
11
12
13
it('cannot log in using the request due to missing CSRF token', () => {
cy.request({
method: 'POST',
url: '/submit-csrf-form',
body: {
username: 'Joe',
},
failOnStatusCode: false,
})
// confirm the server rejects the API call with status code 403
.its('status')
.should('equal', 403)
})

The test confirms we cannot directly make API call without CSRF token

Similarly, we can try including some random csrf token, and it should be rejected also.

We need CSRF token

Ok, there is a way to do it, and we don't need to use cy.visit. Here is what we can do - and it is still much faster than visiting the page, loading all resources, evaluating and running JavaScript, etc. We wil request the page using cy.request, we will parse its HTML to extract the token string, and then we will make the cy.request that will succeed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
it('can log in by extracting the CSRF token from the page', () => {
// make a request to get the "csrf-form.html" page
cy.request('/csrf-form.html')
// get its body field - it is HTML text
.its('body')
.then((html) => {
// find the csrf input element in the HTML
// returned by the server
// Tip: use Cypress.$ to parse the HTML text
// and get the value attribute of the csrf field
const $input = Cypress.$('<html/>')
.html(html)
.find('form input[name=csrf]')
return $input.attr('value')
})
.should('be.a', 'string')
.then((csrf) => {
// make a new request to POST /submit-csrf-form
// and include the CSRF token in the request
cy.request({
method: 'POST',
url: '/submit-csrf-form',
body: {
username: 'Joe',
csrf,
},
})
// the server should accept the CSRF token
// and return the registration page HTML
.its('body')
.then((html) => {
// write the HTML into the document
cy.document().invoke(
{
log: false,
},
'write',
html,
)
})
})
// confirm the registration page shows the correct username
cy.contains('[data-cy=username]', 'Joe')
})

Submit the CSRF form using two cy.request commands

Great, it is working, and even on my local machine using cy.request is much faster than cy.visit. It is in the order of 100ms vs 500ms for the same test to submit the form.

CSRF cookie

Lately, the hidden CSRF input field has been superseded by using a cookie. When you visit the page, the server sends a cookie. Let's check it out. I have another page with CSRF cookie and here is a test that checks the cookie is set and sent with the POST form.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
it('sends the CSRF cookie with the submitted form', () => {
cy.visit('/csrf-form-cookie.html')
// confirm the page has a cookie named "_csrf"
cy.getCookie('_csrf')
.should('exist')
.its('value')
.should('be.a', 'string')
.then((csrf) => {
cy.get('[name=username]').type('Joe')
cy.intercept('POST', '/submit-csrf-form-cookie').as(
'submit',
)
cy.contains('button', 'Register').click()
cy.location('pathname').should(
'equal',
'/submit-csrf-form-cookie',
)
cy.contains('[data-cy=username]', 'Joe')
// get the network "submit" intercept
// and confirm its request has the CSRF cookie sent
cy.wait('@submit')
.its('request.headers.cookie')
.should('include', `_csrf=${csrf}`)
})
})

Confirm the CSRF cookie is set and sent by the page

Set the cookie without visiting the page

Ok, so how do we submit the form protected by CSRF cookie without visiting the page (because it is slow, remember)? Easy - cy.request command is even better than parsing HTML ourselves. Here is the test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('sends the cookie using cy.request', () => {
// request the "csrf-form-cookie.html" page
// using cy.request and NOT cy.visit
cy.request('/csrf-form-cookie.html')
// using cy.request should send the cookies automatically
// confirm the cookie "_csrf" is set
cy.getCookie('_csrf')
.should('exist')
.its('value')
.should('be.a', 'string')
// now let's make another request and it should have CSRF cookie set
cy.request({
method: 'POST',
url: '/submit-csrf-form-cookie',
body: {
username: 'Joe',
},
})
// confirm the server accepts our API call
.its('status')
.should('equal', 200)
})

Making fast API requests to CSRF pages with cookies set automatically

Fast and easy.