Cross-site request forgery (CSRF) protection has been around in the form of protect_from_forgery
since Rails 2 but somehow it's also the most misunderstood feature in the Rails community. To many Rails developers, the protection might seem like magic and thus the details of how it works are ignored like a black box. In this blog post, I will open up the black box and show how, in some situations, the protection might fail to protect your applications. I will talk mainly about the protection mechanism in Rails. For more on CSRF and how it is usually employed by attackers in web applications, please check out its Wikipedia article.
How protect_from_forgery Works
The protect_from_forgery
method in Rails 4.2.6, which is the current stable version, turns on request forgery protection and checks for the CSRF token in non-GET and non-HEAD requests.
def protect_from_forgery(options = {})
self.forgery_protection_strategy = protection_method_class(options[:with] || :null_session)
self.request_forgery_protection_token ||= :authenticity_token
prepend_before_action :verify_authenticity_token, options
append_after_action :verify_same_origin_request
end
def protection_method_class(name)
ActionController::RequestForgeryProtection::ProtectionMethods.const_get(name.to_s.classify)
rescue NameError
raise ArgumentError, 'Invalid request forgery protection method, use :null_session, :exception, or :reset_session'
end
Here we see that when the method is called, it initializes the forgery protection strategy. When an invalid CSRF token is encountered, an application can either
- Raise an exception - See the
ProtectionMethods::Exception
class - Reset the session - See the
ProtectionMethods::ResetSession
class - Null the session - See the
ProtectionMethods::NullSession
class
If the application does not specify a strategy, it will default to nulling the session.
The method also prepends the verify_authenticity_token
check before all controller actions.
def verify_authenticity_token
mark_for_same_origin_verification!
if !verified_request?
if logger && log_warning_on_csrf_failure
logger.warn "Can't verify CSRF token authenticity"
end
handle_unverified_request
end
end
This method verifies the CSRF token in the request, logs a warning message if it's an unverified token and handles the unverified request.
def handle_unverified_request
forgery_protection_strategy.new(self).handle_unverified_request
end
handle_unverified_request
will then create an instance of the protection strategy class out of the three possible ones and call its handle_unverified_request
method. The method would then either resets or nulls the session or raise an exception.
When protect_from_forgery Isn't Enough
The implementation that I've just described might seem sound at first but you might be surprised if we dig deeper. For my example application, I have a ProductsController
with a destroy action and I prepended a method before the destroy action to verify that the current user can destroy the product.
class ProductsController < ApplicationController
before_action :set_product, only: [:destroy]
prepend_before_action :can_delete_product?, only: [:destroy]
# DELETE /products/1
def destroy
@product.destroy
end
private
def set_product
@product = Product.find(params[:id])
end
def can_delete_product?
current_user.can_delete_product?
end
end
And here in my ApplicationController
, I have a call to protect_from_forgery
without any argument, a method that is called before every controller's action to ensure that the user is logged in and I have a getter that returns a reference to the current_user
. Things you would expect in a typical Rails application.
class ApplicationController < ActionController::Base
protect_from_forgery
before_action :login_required
def current_user
@current_user ||= login_from_session
end
def logged_in?
!current_user.nil?
end
def login_required
unless logged_in?
flash[:danger] = 'Please log in'
redirect_to login.url
end
end
def login_from_session
if session[:user_id]
@user = User.find_by_id(session[:user_id])
end
end
end
However, when I make a request to destroy a product without a valid CSRF token, I noticed that my request went through:
Notice that in the logs, the CSRF token is invalid and the request isn't authentic but my request to destroy the product went through, as shown by the SQL statement to delete the product from the products table.
So What's Going On?
As I have mentioned earlier, protect_from_forgery
nulls the session by default and allows the request to go through. If you're memoizing certain objects in your controller, your session may be nulled but the object remains memoized in your controller. Particularly, in my application, I've memoized the @current_user
instance variable with:
def current_user
@current_user ||= login_from_session
end
And this getter happens to be called before the CSRF check in verify_authenticity_token
because I have prepended the can_delete_product?
callback method before my destroy action. If I print the callback chain, this is what I get:
[:can_delete_product?, :verify_authenticity_token, ... , :login_required, :set_product]
If we follow the callback chain, this is what actually happens:
can_delete_product?
is called first and it memoizes@current_user
by retrieving it from the session.verify_authenticity_token
nulls the session because my CSRF token is invalid.login_required
thinks I am logged in although the session is nulled out because@current_user
is already memoized.- Lastly, we enter
ProductsController#destroy
and the product is destroyed by a forged request.
How I Discovered This?
I first encountered this when I found this issue in devise_invitable. Although the gem has protect_from_forgery
turned on, it memoizes the @current_inviter
instance variable in the controller.
def current_inviter
@current_inviter ||= authenticate_inviter!
end
And it also prepended callbacks before the CSRF token is verified.
prepend_before_filter :has_invitations_left?, :only => [:create]
The has_invitations_left?
callback initializes and memoizes the @current_inviter
instance variable, and thus forged requests will bypass the CSRF token check, rendering the application vulnerable.
How to Fix It?
The obvious fix would be to call protect_from_forgery
with the :exception
protection strategy.
protect_from_forgery with: :exception
This will raise an ActionController::InvalidAuthenticityToken
exception and your application will not serve the request. But, this is not enough. Your prepended callbacks will still be called before the action. One workaround would be to change prepend_before_action
to before_action
and prepend_around_action
to around_action
and your callbacks would be called after the CSRF check but if you have to use the prepend_*
variant of prepending callbacks, you should ensure that your callbacks are idempotent and free from side effects.
The good news is that newly generated Rails 4 applications have the protect_from_forgery with: :exception
call inserted into their ApplicationController
but still it is not a silver bullet and the developer has be careful with the use of prepend_*
callbacks.
Also, brakeman just added a check last year to ensure that Rails 4 applications add the with: :exception
argument to the protect_from_forgery
call. Running brakeman on your application would check that your application raises an exception if the CSRF token is invalid.