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 headers are correct the request goes through and followes
- If the chain is not halted here,
Devise
and/orCanCan
or other mechanics kick in.
- Then
- 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
- Pass the request down the chain, and now it followes the same path as
- If we detect it’s not a mobile request, we call
super
and let Rails handle it.
- Then first
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.
1 Pingback