/mar 29, 2016

When Rails' protect_from_forgery Fails

By Jason Yeo

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

  1. Raise an exception - See the ProtectionMethods::Exception class
  2. Reset the session - See the ProtectionMethods::ResetSession class
  3. 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:

log

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:

  1. can_delete_product? is called first and it memoizes @current_user by retrieving it from the session.
  2. verify_authenticity_token nulls the session because my CSRF token is invalid.
  3. login_required thinks I am logged in although the session is nulled out because @current_user is already memoized.
  4. 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.

Related Posts

By Jason Yeo

Jason is a software engineer at Veracode working on SourceClear's Agent team.