Preventing cross-site request forgery
CSRF is an attack where a malicious web application causes a user’s web browser to execute an action on another trusted web application where the user is logged in.
In this recipe, we’re going to learn how to secure an Express.js server against CSRF attacks.
Important note
Browser security has improved significantly in recent years. It’s very difficult to replicate a CSRF attack on any modern browser. However, as there are still many users on older browsers, it’s important to understand how these attacks work and how to protect against them. In this recipe, we’ll replicate a CSRF attack on the same domain. Please refer to the Developers: Get Ready for New SameSite=None; Secure Cookie Settings (https://blog.chromium.org/2019/10/developers-get-ready-for-new.html) Chromium blog, which covers some of the updates that have been made to Google Chrome to prevent CSRF attacks.
Getting ready
Follow these steps:
- Start by creating a directory named
express-csrf
for this recipe and initializing the project withnpm
:$ mkdir express-csrf $ cd express-csrf $ npm init --yes $ npm install express express-session body-parser
- Create a file named
server.js
. This will contain our server, which is vulnerable to CSRF attacks:$ touch server.js
- In
server.js
, import the required modules and register theexpress-session
middleware:const express = require('express'); const bodyParser = require('body-parser'); const session = require('express-session'); const app = express(); const mockUser = { username: 'beth', password: 'badpassword', email: '[email protected]' }; app.use( session({ secret: 'Node Cookbook', name: 'SESSIONID', resave: false, saveUninitialized: false }) ); app.use(bodyParser.urlencoded({ extended: false }));
- Next, in
server.js
, we need to define the routes for our server:app.get('/', (req, res) => { if (req.session.user) return res.redirect('/account'); res.send(` <h1>Social Media Account - Login</h1> <form method="POST" action="/"> <label> Username <input name=username> </label> <label> Password <input name=password type=password> </label> <input type=submit> </form> `); }); app.post('/', (req, res) => { if ( req.body.username === mockUser.username && req.body.password === mockUser.password ) { req.session.user = req.body.username; } if (req.session.user) res.redirect('/account'); else res.redirect('/'); }); app.get('/account', (req, res) => { if (!req.session.user) return res.redirect('/'); res.send(` <h1>Social Media Account - Settings</h1> <p> Email: ${mockUser.email} </p> <form method="POST" action=/update> <input name=email value="${mockUser.email}"> <input type=submit value=Update > </form> `); }); app.post('/update', (req, res) => { if (!req.session.user) return res.sendStatus(403); mockUser.email = req.body.email; res.redirect('/'); });
- Then, add the following to
server.js
to start the server:app.listen(3000, () => { console.log('Server listening on port 3000'); });
Now, we’re ready to start this recipe.
How to do it…
First, we’ll create a malicious web page that can replicate a CSRF attack. After that, we’ll learn how to protect our Express.js server against these attacks.
Your steps should be formatted like so:
- Start the server:
$ node server.js Server listening on port 3000
- Navigate to
http://localhost:3000
in your browser and expect to see the following HTML login form. Enterbeth
as the username andbadpassword
as the password. Then, click Submit:
Figure 9.9 – Social Media Account – Login
- Once logged in, you should be taken to the Settings page of the demo social media profile. Notice that there’s a single field to update your email. Try updating the email to something else. You should see that the update is reflected after clicking Update:
Figure 9.10 – Social Media Account – Settings
- Now, we’re going to create our malicious web page. Create a file named
csrf-server.js
. This is where we’ll build our malicious web page:$ touch csrf-server.js
- Add the following code to create the malicious web page:
const http = require('node:http'); const attackerEmail = '[email protected]'; const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` <iframe name=hide style="position:absolute;left:- 1000px"></iframe> <form method="post" action="http://localhost:3000/update" target=hide> <input type=hidden name=email value="${attackerEmail}"> <input type=submit value="Click this to win!"> </form>`); }); server.listen(3001, () => { console.log('Server listening on port 3001'); });
- In a second terminal window, start the
csrf-server.js
server:$ node csrf-server.js Server listening on port 3001
Important note
In a real CSRF attack, we’d expect the attack to come from a different domain to the vulnerable server. However, due to advances in web browser security, many CSRF attacks are prevented by the browser. For this recipe, we’ll demonstrate the attack on the same domain. Note that CSRF attacks are still possible today, particularly as many users may be using older browsers that don’t have the latest security features to protect against CSRF attacks.
- Navigate to
http://localhost:3001
in your browser. Expect to see the following output showing a single button:
Figure 9.11 – Malicious CSRF web page showing a suspicious “Click this to win!” button
- Click the Click this to win! button. By clicking the button, an HTTP
POST
request is sent tohttp://localhost:3000/update
, with a body containing the[email protected]
email. By clicking this button, the HTTPPOST
request has been sent to the real website’s server, leveraging the cookie stored in the browser. - Go back to the social media profile page and refresh it. We’ll see that the attacker has managed to update the email address:
Figure 9.12 – The Social Media Account – Settings page showing that the email has been updated to [email protected]
- Now, let’s fix the server so that it isn’t susceptible to CSRF attacks. First, copy the
server.js
file to a file namedfixed-server.js
:$ cp server.js fixed-server.js
- To fix the server, we need to add some additional configuration to the
express-session
middleware. Change theexpress-session
configuration to the following:app.use( session({ secret: 'Node Cookbook', name: 'SESSIONID', resave: false, saveUninitialized: false, cookie: { sameSite: true } }) );
Note the addition of the
{ cookie : { sameSite : true }}
configuration. - Now, having stopped the original server, start
fixed-server.js
:$ node fixed-server.js Server listening on port 3000
- Return to
http://localhost:3000
and log in again with the same credentials as before. Then, in a second browser tab, visithttp://127.0.0.1:3001
(csrf-server.js
should still be running) and click the button again. Note that you must navigate usinghttp://127.0.0.1:3001
rather thanhttp://localhost:3001
; otherwise, the request will be considered as coming from the same domain.You’ll find that this time, clicking the button will not update the email on the Social Media Account - Settings page. If we open Chrome DevTools | Console, we’ll even see a 403 (Forbidden) error, confirming that our change has prevented the attack:
Figure 9.13 – The Chrome DevTools window showing 403 (Forbidden) on our CSRF request
This recipe has demonstrated a simple CSRF attack and the associated risks. We mitigated the vulnerability by supplying additional configuration using the express-session
middleware.
How it works…
In this recipe, we demonstrated a simple CSRF attack. The attacker crafted a malicious site to leverage a cookie from a social media website to update a user’s email to their own. This is a dangerous vulnerability as once an attacker has updated the email to their own, they can end up with control over the account.
To mitigate this vulnerability, we passed the express-session
middleware the { cookie : { sameSite : true }}
configuration. The SameSite
attribute of the cookie header can be set to the following three values:
none
: The cookie can be shared and sent in all contexts, including cross-origin requestslax
: This allows the cookie to be shared with HTTPGET
requests initiated by third-party websites, but only when it results in top-level navigationstrict
: Cookies can only be sent through a request in a first-party context – if the cookie matches the current site URL
Setting the { sameSite : true }
configuration option in the express-session
middleware configuration equates to setting the Set-Cookie : SameSite
attribute to strict
mode.
Inspecting the header of the request in this recipe would show a Set-Cookie
header similar to the following:
Set-Cookie: SESSIONID=s%3AglL_...gIvei%2BEs; Path=/; HttpOnly; SameSite=Strict
There’s more…
Some older browsers don’t support the Set-Cookie SameSite
header attribute. A strategy for dealing with these cases is to generate an anti-CSRF token. These anti-CSRF tokens are stored in the user session, which means the attacker would need access to the session itself to carry out the attack.
We can use a module named csurf
to help implement anti-CSRF tokens:
- Still in the
express-csrf
directory, copyfixed-server.js
to a new file namedcsurf-server.js
:$ cp fixed-server.js csurf-server.js
- Install the
csurf
module:$ npm install csurf
- Next, we need to import and initialize the
csurf
module in thecsruf-server.js
file. Add the following lines below theexpress-session
import:const csurf = require('csurf'); const csrf = csurf();
- Then, we need to alter the HTTP
GET
request handler so that it uses thecsrf
middleware. We can achieve this by supplying it as the second parameter to theget()
method of the/account
route handler:app.get('/account', csrf, (req, res) => { if (!req.session.user) return res.redirect('/'); res.send(` <h1>Social Media Account - Settings</h1> <p> Email: ${mockUser.email} </p> <form method="POST" action=/update> <input type=hidden name=_csrf value="${req.csrfToken()}"> <input name=email value="${mockUser.email}"> <input type=submit value=Update > </form> `); });
In the HTML template, we generate and inject
csrfToken
using thereq.csrfToken()
method of the request object. We inject the token into the HTML template as a hidden field named_csrf
. Thecsrf
middleware looks for a token with that name. - We also need to update the
post()
method of our/update
route handler so that it can use thecsrf
middleware:app.post('/update', csrf, (req, res) => { if (!req.session.user) return res.sendStatus(403); mockUser.email = req.body.email; res.redirect('/'); });
Upon an HTTP
POST
request, thecsrf
middleware will check the body of a request for the token stored in the_csrf
field. The middleware then validates the supplied token with the token stored in the user’s session. - Start the server:
$ node csurf-server.js Server listening on port 3000
- Navigate to
http://localhost:3000
and log in with the same username and password that we used in this recipe. Click on View Page Source on the Social Media Account - Settings page. You should see the following HTML showing the hidden_csrf
field:<html> <head></head> <body> <h1>Social Media Account - Settings</h1> <p> Email: [email protected] </p> <form method="POST" action="/update"> <input type="hidden" name="_csrf" value="r3AByUA1-csl3hIjrE3J4fB6nRoBT8GCr9YE"> <input name="email" value="[email protected]"> <input type="submit" value="Update"> </form> </body> </html>
You should be able to update the email as before.
The csurf
middleware helps mitigate the risk of CSRF attacks in older browsers that don’t support the Set-Cookie:SameSite
attribute. However, our servers could still be vulnerable to more complex CSRF attacks, even when using the csurf
middleware. The attacker could use XSS to obtain the CSRF token, and then craft a CSRF attack using the _csrf
token. However, this is best-effort mitigation in the absence of support for the Set-Cookie:SameSite
attribute.
Slowing an attacker down by making the attack they have to create more complex is an effective way of reducing risk. Many attackers will try to exploit many websites at a time – if they experience a website that takes significantly longer to exploit, in the interest of time, they will often just move on to another website.
See also
- The Authentication with Fastify recipe in this chapter
- The Hardening headers with Helmet recipe in this chapter
- The Anticipating malicious input recipe in this chapter
- The Preventing JSON pollution recipe in this chapter
- The Guarding against cross-site scripting recipe in this chapter
- The Diagnosing issues with Chrome DevTools recipe in Chapter 12