Jason Codes

CSRF vulnerability in Ruby on Rails 2.3.10 & 3.0.3

Posted (updated )

Yesterday Rails 3.0.4 and Rails 2.3.11 were released with patches for a few security issues. The two you're most likely to be affected by are CSRF Protection Bypass in Ruby on Rails (CVE-2011-0447) and Potential SQL Injection in Rails 3.0.x (CVE-2011-0448). I'll be looking at the CSRF issue here. The Riding Rails blog has a post on the CSRF protection patch which is worth reading.

The problem

It has been discovered that browser plugins such as Flash and Java in some circumstances can bypass the same origin policy and send requests with a X-Requested-With: XMLHttpRequest header and POSTing with a different content type than normally used by forms (i.e. application/javascript instead of application/x-www-form-urlencoded or multipart/form-data). These methods are used by Rails to detect the difference between form POSTs which need the authenticity token field and AJAX requests (from jQuery or Prototype) which do not. This seemed like a good way to prevent cross-site request forgeries since these headers can't be set for a POST via a <form/> and the same origin policy prevents XMLHttpRequest requests from other sites.

For more detail on how this vulnerability works, see CSRF: Flash + 307 redirect = Game Over on the Web Application Security Consortium mailing list.

The fix

Rails 3.0.4 and Rails 2.3.11 have patches for this issue in commits ae19e41 and 7e86f9b respectively. With this patch, Rails now marks all non-GET requests which don't contain a valid authenticity token as unverified. This means AJAX POST requests will now need to pass send the authenticity token in a HTTP header.

The method for handling invalid requests (that is, POSTs without a valid authenticity token) has also changed with this patch. Instead of raising an InvalidAuthenticityToken exception, Rails now calls handle_unverified_request which by default clears your session data. The idea of this is that unverified requests cannot do any damage if they don't have access to any persistent state (like your user session).

A nice thing about this new setup is if you use HTTP authentication or an X-API-Token type header for API requests, they'll continue to function fine as these authentication systems don't use session data.

Authlogic

Authlogic however stores authentication data in a cookie which by default is called user_credentials. This is separate from session data so we have to override handle_unverified_request in the ApplicationController to clear this ourselves.

Update: If you run plugins which initialise current_user before protect_from_forgery runs (such as paper_trail which adds a before_filter to ApplicationController::Base), by the time handle_unverified_request is called Authlogic will have already logged in and it's too late to prevent authentication by just deleting the cookie. We need to clear the cached @current_user and @current_user_session variables to be sure.

def handle_unverified_request
  super
  cookies.delete 'user_credentials'
  @current_user_session = @current_user = nil
end

Please do not blindly upgrade to 2.3.11/3.0.4 without carefully testing your authentication system with POST requests lacking an authentication token. Without adding the above code we would effectively lose CSRF protection as the app will still see the authentication and process the request where there's an invalid or missing authenticity token.

Devise

If you use Devise's "remember me" functionally, you'll need to clear the persistent cookie. By default this is called remember_user_token. If you're making use of multiple Warden scopes, make sure you handle those as well.

def handle_unverified_request
  super
  cookies.delete 'remember_user_token'
  sign_out :user
end

Update: Devise Security Release 1.1.6 patches this issue for Rails 3 by calling sign_out_all_scopes within handle_unverified_request. Devise 1.0.10 for Rails 2.3 has also been released which backports sign_out_all_scopes and calls it from handle_unverified_request. If you're using Devise, upgrade to these versions or later if possible.

Ajax requests (jQuery)

Since X-Requested-With: XMLHttpRequest is no longer enough to verify authenticity to Rails, we'll need to pass the authenticity token with each non-GET Ajax request. New to 2.3.11 is the csrf_meta_tag form helper which has been backported from Rails 3. If you don't already have csrf_meta_tag in your layout, add the following to the %head section of your (hopefully Haml) layouts:

%head
  = csrf_meta_tag

This adds a couple of meta attributes to every page which jQuery can look up to get the authenticity token. The second part of making Ajax requests work again is to set the X-CSRF-Token header on all Ajax requests with the authenticity token.

Update: Mislav Marohnić points out that Rails uses jQuery 1.5's new ajaxPrefilter feature if available in preference to beforeSend. Using ajaxPrefilter instead of beforeSend is preferable as a beforeSend hook would be overridden if any Ajax call uses the beforeSend option itself. ajaxPrefilter allows multiple filters to be added and thus avoids this issue.

If you're using the latest jquery-ujs in your Rails 3 app, you shouldn't need any custom code at all for CSRF protection to work. If you're on Rails 2 (or for some reason stuck using Prototype for UJS), add the following to your main application.js file to send the CSRF token with jQuery Ajax requests:

// Make sure that every Ajax request sends the CSRF token
function CSRFProtection(xhr) {
 var token = $('meta[name="csrf-token"]').attr('content');
 if (token) xhr.setRequestHeader('X-CSRF-Token', token);
}
if ('ajaxPrefilter' in $) $.ajaxPrefilter(function(options, originalOptions, xhr) { CSRFProtection(xhr); });
else $(document).ajaxSend(function(e, xhr) { CSRFProtection(xhr); });

Avoiding authentication denial of service (DoS)

One big thing I don't like about this fix is that it opens up an opportunity for a remote host to kill your login session. This can happen because any POST request with an invalid or missing authenticity token will kill your session data. Any other site with a <script/> block could POST a form to your site to log you out. It's almost as bad as making your logout link a GET instead of a POST.

Because of this I decided I'd rather have the previous behaviour of raising an InvalidAuthenticityToken exception to reject unverified requests completely:

def handle_unverified_request
  raise ActionController::InvalidAuthenticityToken
end

A side affect of this is that any POST requests outside of your web UI (such as to an API) will now fail as they aren't passing in the X-CSRF-Token header. We can get the best of both worlds by raising InvalidAuthenticityToken for web requests and clearing session for all other requests.

def handle_unverified_request
  content_mime_type = request.respond_to?(:content_mime_type) ? request.content_mime_type : request.content_type
  if content_mime_type && content_mime_type.verify_request?
    raise ActionController::InvalidAuthenticityToken
  else
    super
    cookies.delete 'user_credentials'
    @current_user_session = @current_user = nil
  end
end