Understanding CSRF Tokens

Why they are important and how to make them effective

SteveLTN
SteveLTN

--

TL;DR

CSRF tokens work. But not alone. To protect your site form CSRF attacks, you also need to:

  • Set CORS headers correctly
  • Never mutate site state on GET requests

CSRF stands for Cross-Site Request Forgery. I’m not digging into details now as I’ll show it to you in just a second. All we need to know for now is that a malicious website can drive the users’ browsers to send requests to another website without the users’ acknowledgements.

Our experiment started with a discussion between my colleague Simon and I. We have an app with Rails backend and React.js fronted. We know that Rails has CSRF token verification by default. It verifies that the CSRF token in the request headers or in form data matches the one in the encrypted cookie on each non-GET request. But it’s painful to configure on a single-page app using, for instance, React.js. Since neither of us had enough knowledge of how CSRF tokens really work, we turned to Google. Unfortunately, we couldn’t find an article describes it clearly. So we decided to make some little experiments.

Though the examples here are in Rails, the general concept applies to other web frameworks as well.

Hacking Our Own Site. Attempt 1

We built a minimal Rails app and had it running on site.alice.com which we will call “Site Alice” from now on. Site Alice merely allows us to CRUD posts. We deliberately turned off CSRF token verification and allowed CORS for all domains.

We also built a supposably malicious “Site Mallory”, who has only one static HTML page with the following snippet:

We opened Site Mallory in our browser. Boom! A new post is created in Site Alice!

What happened here?

The JavaScript on Site Mallory drove user’s browser to send a POST request to Site Alice. When browsers do so, they include Site Alice’s cookies even if the request is initiated by Site Mallory. From Site Alice’s perspective, the user posted a post by him/herself. This all happened without the user knowing it — he/she merely opened a webpage.

That’s what CSRF Tokens are for

By adding protect_from_forgery to controllers, Rails:

  • stores a CSRF token in the user’s encrypted cookie. This token doesn’t change as long as the cookie is not cleared
  • includes the token as a hidden field authenticity_token in all Rails-rendered forms
  • includes the token in AJAX request headers if you use jquery-rails
  • verifies the token in form data or in AJAX headers, make sure either of them matches the token stored in the cookie

For single-page apps who most likely don’t send AJAX requests by jquery-rails, Rails API Doc suggests you to render the CSRF token into a meta tag with helper csrf_meta_tag and:

For AJAX requests other than GETs, extract the “csrf-token” from the meta-tag and send as the “X-CSRF-Token” HTTP header.

So we added protect_from_forgery to our controller. It worked indeed. Site Mallory’s forged request would’t go through as Rails blocks it when verifying CSRF tokens.

You might ask, since we were to render CSRF token into HTML, what prevents us from sending a GET request and extract the token from the HTML using JavaScript? Let’s see our second attempt to hack Site Alice.

Hacking Our Own Site. Attempt 2

We changed the script tag on Site Mallory to:

The POST request went through ! All we did is just:

  1. send a GET request to fetch an HTML page which contains the CSRF token
  2. extract the token and include it in the headers of next POST request.

CSRF tokens didn’t seem to provide much protection at all.

CORS Headers!

Well, we are not totally vulnerable because we have CORS headers! Let’s set up CORS headers properly so scripts on Site Mallory couldn’t send requests to Site Alice! Problem solved!

Wait a minute… If CORS solves the problem, why do we need CSRF tokens at all? Are they just redundant? Let’s find out with hacking attempt 3.

Hacking Our Own Site. Attempt 3

This time we have proper CORS headers, but no CSRF token verification. According to MDN,

For security reasons, browsers restrict cross-origin HTTP requests initiated from within scripts.

What if it’s not initiated within scripts? To answer that, we have our third HTML snippet:

The value of authenticity_token is empty, but the request still went through! It turned out, same-origin policy doesn’t apply to form submissions! Ouch!

That made it pretty clear that proper CORS headers and CSRF token verification are both necessary. But is there another way in even if we use both of them?

Hacking Our Own Site. Attempt 4

This time the snippet on Site Mallory is extremely simple:

with a caveat: we temporary created another route who maps a GET request to posts#create action on Site Alice:

get '/posts/create', controller: 'posts#create'

When the page on Site Mallory loaded, we immediately see a new post created on Site Alice.

By default, same-origin policy only applies on requests other than GET , a simple malicious image link would trick the browser and create a post without user’s knowing it.

That why we should never mutate site state on GET requests.

Conclusion

Now we know why CSRF tokens are necessary and why itself is not enough.

In order to protect our sites from CSRF attack, we need to:

  • Have CSRF token verification
  • Set CORS headers correctly
  • Never mutate site state on GET requests

Now as we fully understand it, we know the key concept of CRSF tokens is that they are accessible by request initiated by its own site but not by other sites’ requests. To meet this requirement, rendering CSRF tokens into a meta tag is not the only way. To make it easier on our React site, we decided to keep it in Redux store.

--

--