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
- The server checks the CSRF token
- Making API requests
- We need CSRF token
- CSRF cookie
- Set the cookie without visiting the page
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.
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 | // https://github.com/anonrig/fast-querystring#readme |
The server checks the CSRF token
The first thing the server does it check if the CSRF token is present.
1 | fastify.post('/submit-csrf-form', (request, reply) => { |
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 | it('rejects the form with missing CSRF token', () => { |
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:
1 | if (!csrfTokens[csrf]) { |
Ok, time to write a test. Instead of removing the CSRF form input field, let's set its value to something random.
1 | it('rejects the form with incorrect CSRF token', () => { |
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 | it('cannot log in using the request due to missing 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 | it('can log in by extracting the CSRF token from the page', () => { |
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.
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 | it('sends the CSRF cookie with the submitted form', () => { |
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 | it('sends the cookie using cy.request', () => { |
Fast and easy.