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