Caching the Uncacheable: CSRF Security

Cross-Site Request Forgery attacks are a risk for any web application that accepts user data. The best mitigation technique is to use an "authenticity token" that the server generates when building forms for the user and checks when the forms are submitted. Unfortunately, the presence of this token makes the page uncacheable. In this post, I investigate several strategies for maintaining security while improving cacheability. I use Ruby on Rails for the examples, but the techniques apply to nearly any web application framework.

Cross-Site Request Forgery

According to Open Web Application Security Project (OWASP), Cross-Site Request Forgery is: "an attack which forces an end user to execute unwanted actions on a web application in which he/she is currently authenticated. With a little help of social engineering (like sending a link via email/chat), an attacker may trick the users of a web application into executing actions of the attacker's choosing. If the targeted end user is a normal user, a successful CSRF attack can compromise sensitive data. If the targeted end user is the administrator account, this type of attack can compromise the entire web application."

The strongest protection against CSRF attacks is for the origin server to generate a unique token for each user, embed it in each generated page, and then assert that any request from the page that modifies data includes the secret token.

Ruby on Rails not only supports all of this, but enables it by default. It generates an authenticity_token for each user session. Whenever Rails generates an HTML page, it includes the following HTML:

<meta content="authenticity_token"
      name="csrf-param" />
<meta content="gv1xdpH2w4MYcMtoT52pRPV+tPoWFDSJhxNiBOC5idQ="
      name="csrf-token" />

Then, any <form> submission or AJAX request back to the server with an HTTP method other than GET includes that token as a query parameter or HTTP header. On the server, Rails checks the submitted token against the one it generated for the user. If there’s a mismatch, it discards the attempted update.

Cacheability

Rails is doing us a great service, but it poses a problem: these tokens are dynamic, user-specific content and thus make the whole page “uncacheable.” You certainly wouldn’t want one user getting another user’s authenticity token.

There are several different mechanisms we can use to keep the CSRF protection and restore caching.

Technique 1: ESI

Edge-Side Includes let you embed dynamic content within a cached page. In this case, you will use ESI for the CSRF meta tags that Rails produces. First, change the CSRF part of layouts/application.html.erb layout from

<head>
  <%= csrf_meta_tags %>
</head>       

to

<head>
  <esi:include src="/csrf_meta_tags.html" />
</head>

Then, create a new Rails template that contains just <%= csrf_meta_tags %> and tell Rails to route /csrf_meta_tags.html to that template. Be sure that the HTTP response headers tell Varnish not to cache this resource.

Of all the techniques I outline in this post, this is the fastest and most secure.

If your caching layer doesn’t support ESI, a second option is to put the token in an HTTP cookie and then pull it out with JavaScript. With the right configuration, a cookie is a relatively secure and performant mechanism for communicating a small secret token from the server to the browser.

First, remove the <%= csrf_meta_tags %> call from layouts/application.html.erb. Then, ensure the cookie is only transmitted over HTTPS by adding secure to its attributes:

cookies[:csrftoken] = {
  value: form_authenticity_token,
  expires: 1.day.from_now,
  secure: true
}

This will generate something like

Set-Cookie: csrftoken=gv1xdpH2w4MYcMtoT52pRPV+tPoWFDSJhxNiBOC5idQ=; path=/; Expires=12/31/2014; secure;

Cookies have another layer of protection called httponly that tells the browser to send the cookie on each request, but block access from JavaScript. Unfortunately, access from JavaScript is exactly what we need in this case. Thus, it is strongly recommended that you put the CSRF token in one cookie rather than adding it the session cookie Rails provides by default.

The JavaScript in the browser now has access to the CSRF token. Making it available to the Rails JavaScript handlers requires a small script that runs on DOM-load:

// The following code assumes you have jQuery and https://github.com/carhartl/jquery-cookie

// On DOM-load, get the "authenticity-token" cookie and set up a <meta> tag for Rails:
jQuery(function($) {
  var token = $.cookie('authenticity-token');

  $('<meta>')
    .attr('name', "csrf-param")
    .attr('content', 'authenticity_token')
    .appendTo('head');

  $('<meta>')
    .attr('name', 'csrf-token')
    .attr('content', token)
    .appendTo('head');
});

The cookie-based approach has some downsides when compared to the ESI approach. Most notably, the additional cookie is sent on every request; it may be small, but it still wastes bandwidth and slows down the application. Additionally, unless you separate the token into its own cookie, having to remove the httponly control exposes your application to additional attack vectors.

Technique 3: Token Over XHR

A third option is to convey the token not via cookie but over a separate XHR request.

As above, first remove the <%= csrf_meta_tags %> call from layouts/application.html.erb. Next, create a new Rails action that returns the CSRF tags as JSON:

def csrf_meta
  respond_to do |format|
    format.json do
      render json: {
        param: request_forgery_protection_token,
        token: form_authenticity_token
      }
    end
  end
end

Finally, add some JavaScript to fetch the token and add it to the DOM:

jQuery(function($) {
  $.getJSON('/csrf_meta.json').then(function(json) {
    $('<meta>')
      .attr('name', "csrf-param")
      .attr('content', json.param)
      .appendTo('head');

    $('<meta>')
      .attr('name', 'csrf-token')
      .attr('content', json.token)
      .appendTo('head');
  });
});

The primary downside to this approach is that the XHR call will take some time. This means that the token won’t be available as soon as the page loads. If the user submits a form or clicks a “delete” button very quickly, it might fail due to the token being missing. You can fix this by marking the XHR as synchronous, but that further delays the page load. (Note that some browsers disallow synchronous XHR in the main thread.)

Recap

Cross-Site Request Forgery attacks are nasty. Fortunately, most web application frameworks provide a way to use an authenticity token to mitigate the risk. If you want to cache a page that has such a token, you will need to separate the token from the rest of the page. You can combine the pieces with Edge-Side Includes for optimum speed and security. Or, if your caching layer doesn’t support ESI, you can convey the token separately in an HTTP cookie or over an XHR request. Regardless, make sure to lock down the token as much as possible; anybody with access to tokens can impersonate users in your system.

Further Reading

James A Rosen
Lead User Experience Engineer
Published

5 min read

Want to continue the conversation?
Schedule time with an expert
Share this post
James A Rosen
Lead User Experience Engineer

James Rosen is a lead user experience engineer at Fastly.

Ready to get started?

Get in touch or create an account.