Originally published on https://smellycode.com/csrf-in-action/
Cross-Site Request Forgery(CSRF/XSRF) is one of the most popular ways of exploiting a server. It attacks the server by forcing the client to perform an unwanted action. This attack targets applications where the client/user is already logged in. It mainly changes the state of the server by making inadvertent updates or transfer of data. For example, updating vital information like emails contact numbers, etc. or transferring data from one entity to another.
This post demonstrates CSRF attack and elaborates concepts linger around it. It uses a simple todo app and an evil client--which updates the state of todos--for demonstration. Technologies used:
- ReactJs for client.
- ExpressJs and a couple of middlewares(CORS, body-parser, cookie-parser, etc) for server.
- MongoDb as database and Mongoose for data modeling.
- JWT for stateless session management.
- and a few other stuff.
The sample todo app uses JSON Web Token for stateless session management and authentication. It stores the token in a cookie with httpOnly
flag to make the token inaccessible to the JavaScript running on the client. Picture below depicts the auth flow of the app.
Let's take a gander at the code organization of the app. The codebase has three actors -- a server, a client, and an evil client.
The server exposes a few endpoints for CRUD operations on both user(/users
) and todo(/todos
). It uses mongoose to store data in MongoDB. It also supports cross-origin requests from a client running at localhost:3001
(middleware cors is used to enable cross-origin resource sharing). The server runs at http://localhost:3000.
The client has a simple login form and a todo list. It uses ReactJs to build the UI and axios for ajax calls. When the client is loaded, it fetches todos(GET, /todos
) of the logged in user. If there's an authentication error(status code is 401), it directs the user to login. Todos are successfully fetched only when the user is logged in.
The evil client runs at http://locahost:3002 with the help of the package http-server. It has a plain HTML page and a form. The form opens its action in a hidden iframe for silent submission. The app lures the user to click on a button which stimulates the form submission. Form submission makes a post call to http://localhost:3000/todos/complete which marks todos belonging to the logged in user as complete.
<!DOCTYPE html>
<html>
<body>
<h1>Hey There!</h1>
<p
>Having a rough day! Don't worry, I have got a picture of a cute cat to
cheer you up. <button id="btn_cat">Show me 🐱</button>
</p>
<iframe style="display:none" name="csrf-frame"></iframe>
<form
method="POST"
action="http://localhost:3000/todos/complete"
target="csrf-frame"
id="csrf-form"
>
</form>
<script type="text/javascript">
document.getElementById('btn_cat').addEventListener('click', () => {
document.getElementById('csrf-form').submit();
});
</script>
</body>
</html>
Evil client in action:
Let's address questions which create confusion.
Q: Why no authentication error? 🤔
The server does not throw any authentication error cause the request contains a valid JWT token. The request gets the token from cookies.
When receiving an HTTP request, a server can send a
Set-Cookie
header with the response. The cookie is usually stored by the browser, and then the cookie is sent with requests made to the same server inside aCookie
HTTP header. ~ Mozila
When the user logs in, the JWT is stored in an httpOnly
cookie(see auth flow). Cookies are sent with every request to the same server. Because of that, the JWT becomes part of every request 🤖.
Q: Shouldn't the CORS setup help here?
Let's talk about CORS before jumping to the answer. Browsers limit the interaction of scripts or document loaded on one origin(a tuple of protocol, domain, and port) with another origin to avoid Jungle Raj. The mechanism used for imposing such limitations is known as Same Origin Policy. It ensures that applications are running in isolated environments. Sometimes, developers need to relax the same-origin policy so that applications can interact with each other. That's what originates the idea of Cross-Origin Resource Sharing(CORS). CORS allows site-a
to interact with site-b
only if site-b
agrees--by responding with appropriate HTTP headers. To enable CORS, the server needs a tad of work(the sample todo app uses cors middleware for the same).
In the browser world, ajax requests are classified into three categories:
- Simple Request
- Non-simple request
- Preflight request ✈️.
More details on these can be found here.
Whenever a cross-origin resource is requested using a non-simple request, the browser makes a pre-flight OPTIONS
request. The server responds to the pre-flight request with appropriate response headers. If the origin and the request method are present in Access-Control-Allow-Origin
and Access-Control-Allow-Methods
, the browser originates the main request. Otherwise, a cors error is thrown with a pertinent message.
Network logs of the todo app with preflight requests.
For simple requests, the browser doesn't intiate any preflgiht request. The malicious client leverages this fact to bypass the Same Origin Policy with the help of an HTML form. That's why CORS set up doesn't help here 🤯.
Q: What if WebStorage is used to store JWT instead of httpOnly cookie?
Storing JWT in the Web Storage will make the app less vulnerable for CSRF attacks. But it spikes the chances of the token being compromised. That's because any JavaScript running on the client has access to the web storage. It's DANGEROUS 🛑.
Q: How to prevent CSRF?
The challenge for the server is to validate both the token and the source of the request i.e. origin. The token validation is already implemented. The server needs to verify the source of the request for CSRF protection. The source can either be verified with the help of CORS Origin Header or an XSRF Token. Shielding server with XSRF token(CSRF token) is more reliable and popular than CORS Origin Header.
The implementation of the XSRF token is straight forward. When the client represents valid credentials, the server generates a random unguessable unique string named as xsrfToken
. It puts the xsrfToken
in JWT along with other claims. The server also adds an xsrfToken
in a cookie(why cookie? cause cookies are limited by same-origin policy). Here's a sample JWT payload with xsrfToken
:
{
"sub": "hk",
"xsrfToken": "cjwt3tcmt00056tnvcfvnh4n1",
"iat": 1560336079
}
The client reads the token from cookies and adds the token to request headers as X-XSRF-TOKEN
before making requests. When the server receives a request, it reads xsrfToken
from JWT payload and compares with the X-XSRF-TOKEN
header. If both are same then the request is further processed otherwise it is terminated with status code 401. This technique is also known as Double Submit Cookies method.
The auth flow with XSRF token:
Code version of the same with express-jwt:
const expressJwt = require('express-jwt');
// Paths without token.
const publicRoutes = ['/users/register', '/users/authenticate'];
const isRevoked = async (req, payload, done) => {
const { xsrfToken } = payload;
done(null, xsrfToken !== req.get('X-XSRF-TOKEN'));
};
module.exports = () =>
expressJwt({
secret: process.env.JWT_SECRET,
getToken: req =>
req.get('X-XSRF-TOKEN') && req.cookies.jwtToken
? req.cookies.jwtToken
: null,
isRevoked
}).unless({
path: publicRoutes
});
Client side request interceptor with axios:
import axios from 'axios';
const getCookies = () =>
document.cookie.split(';').reduce((cookies, item) => {
const [name, value] = item.split('=');
cookies[name] = value;
return cookies;
}, {});
const baseURL = 'http://localhost:3000';
const ajax = axios.create({
baseURL,
timeout: 5000,
withCredentials: true
});
// Add a request interceptor
ajax.interceptors.request.use(function(config) {
const xsrfToken = getCookies()['xsrfToken'];
// CSRF Token.
if (xsrfToken) config.headers['X-XSRF-TOKEN'] = xsrfToken;
return config;
});
export default ajax;
Note: Real-world applications require a more elegant mechanism for handling CSRF tokens. You may want to use the middleware csurf.
The evil client after CSRF token:
The final code of the sample app is uploaded here. Thanks for reading 🙏🏻.
Top comments (2)
Really nice post about CSRF!
But the xsrf cookie is not httpOnly then what if in evil-site there's a script to get and put it in X-XSRF-HEADER before sending request? Is there an alternative solution to prevent this? Really want to hear from you.
He mentioned the following: "The server also adds an xsrfToken in a cookie (why cookie? cause cookies are limited by same-origin policy)."
From MDN: Access to data stored in the browser such as Web Storage and IndexedDB are separated by origin. Each origin gets its own separate storage, and JavaScript in one origin cannot read from or write to the storage belonging to another origin. Cookies use a separate definition of origins.
(developer.mozilla.org/en-US/docs/W...)