Okay,

I’m sure everyone has head about CSFR before, or at least uses the protect_from_forgery method inside his Ruby on Rails application. But what if you want to make an API?

Rails says it’s best to disable this feature when building API’s as they are supposed to be stateless. This is all nice and dandy in theory, but many practical examples require some state, or a validation of authentication, or the data representation needs to be bound or scoped to the user making the request. In our case, it was the latter.

So we decided that we enable protect_from_forgery on our API controllers as well. Now what does this actually cause?

First let’s look at the documentation. The documentation gives a good explanation on what the consequences are of enabling this attribute on your controllers:

  • Every request contains a token that needs to be supplied back on a POST.
  • This token changes with every request.
  • This token is stored in the session.
  • API’s or JavaScript calls usually have no clue about this token.

So when you enable this feature, and you have a mobile application like we do, all your calls to the website suddenly fail because the CSFR token is missing. So how do we bypass this problem?

Rails uses the handle_unverified_request method to allow you a final check or attempt to bypass this problem. Except there is one problem when using Devise, this method get’s overwritten by them as well….

To make things worse, not all of our Controllers are DeviseControllers. If you run a test with the Devise SessionController, you can see that there are actually no problems in using that Controller as part of your API. The reason is this:

class Devise::SessionsController < DeviseController
  prepend_before_action :require_no_authentication, only: [:new, :create]
  prepend_before_action :allow_params_authentication!, only: :create

  #snip
end

This is the source of the Devise::SessionController, as you can see they actually remove their authentication requirements and allow parameter authentication on the controller. I did not dive into the code in detail, but my assumption is that they also disable the CSRF check with this.

So what did we initially implement?

We started with overwriting the handle_unverified_request and had some functions that determined whether a request was from a mobile client or not. If we detected this, then we returned true and did not call the super method of Rails/Devise to block the request.

But there was a problem that we actually didn’t see with this. When I wrote more specs, I discovered that whether we checked the headers for authentication was irrelevant. Once you were authenticated with the system and had your session cookie, you could dive into any resource and regardless of the headers being submitted, Devise would allow the request and return the request resource.

Consequence:  Anyone hijacking a valid session would be able to access the resources, regardless if they had the correct headers in place. Just having the headers present would be sufficient to bypass Devise.

How do you fix this?

The fix itself is pretty straight forward. The problem was that we assumed every request was being handled by handle_unverified_request. And this is simply not the case.

When reading the documentation, it clearly states the following:

All requests are checked except GET requests as these should be idempotent. Keep in mind that all session-oriented requests should be CSRF protected, including JavaScript and HTML requests.

So only POST, PUT and DELETE requests are covered by this method. Which explains why every single GET request was going through. Our functions first of all simply returned true/false and did not stop the request chain. And secondly they were never called!

So how does one make his controllers API compatible while keeping Devise and the CSRF checks in place?

# frozen_string_literal: true
class ApplicationController < ActionController::Base
  # Filters
  before_action :validate_mobile_access, if: :mobile_access? # Verify mobile requests

  # Is a request considered a mobile request?
  def mobile_access?
    request.headers['MY_SECRET_HEADER'].present?
  end

  # Is the authentication data present and valid?
  # Abort the request if it isn't
  def validate_mobile_access
    access_hash = request.headers['MY_SECRET_HEADER']

    head(:forbidden) and return unless ::Authenticator.auth(access_hash)
  end

  # Overwrite the behaviour for mobile requests.
  def handle_unverified_request
    return if mobile_access?
    super
  end
end

The above is cleaned snippet of how we tackled the problem. Of course this is just a simple pseudo snippet to not reveal our authentication mechanics, but it illustrates briefly what is required to cover all types of requests when you use Devise and Ruby on Rails:

  • Is your request a GET?
    • Then validate_mobile_access is triggered when the headers are present
      • If the headers are correct the request goes through and followes Devise auth logic
      • If the headers are incorrect we return a FORBIDDEN response
    • If the chain is not halted here, Devise and/or CanCan or other mechanics kick in.
  • Is your request a POST variant?
    • Then first handle_unverified_request kicks in cause the CSRF token is missing
    • If we detect it to be a valid mobile request, we stop the method and don’t call super.
      • Pass the request down the chain, and now it followes the same path as GET
    • If we detect it’s not a mobile request, we call super and let Rails handle it.

But how do I handle authentication requests then? Easy, if you look up at the beginning of the post, you will see that the Devise::SessionsController disables these requests and it will work out of the box.

Hope this sheds some light on the whole complex topic and puts people on the right path when they have to deal with this kind of mess.

Advertisements