4

I have a Rails API that's authenticated with an http-only cookie, and as such I require CSRF protection. From what I can tell, the Rails community seems to prefer storing jwt auth tokens in local storage rather than in a cookie. This avoids the need for CSRF but exposes you to XSS, which is why we chose to use cookies + csrf.

It seems that CSRF protection is disabled by default due to the community preference for local storage. I am trying to enable it with limited success. Here is how I'm attempting to handle it:

module V1
  class ApplicationController < ::ApplicationController
    include Concerns::Authentication
    include ActionController::RequestForgeryProtection
    protect_from_forgery

    protected

    def handle_unverified_request
      raise 'Invalid CSRF token'
    end

    after_action :set_csrf_cookie

    def set_csrf_cookie
      if current_user 
        cookies['X-CSRF-Token'] = form_authenticity_token
      end
    end
  end
end

On the client side, I can see that the token comes back in the cookie. When I make a request, I also see that the token is present in the X-CSRF-Token header. All looks well so far.

However, the verified_request? method returns false, so handle_unverified_request gets invoked. Stepping through the Rails code, I see that my token is present in request.x_csrf_token, but the token appears to fail verification when it's checked against the session. One thing I'm wondering here is if I need to enable something to get the session to work correctly, as I understand that session management isn't turned on be default in API mode. However, if that were the case I would sort of expect attempts to access the session object to blow up, and they don't, so I'm not sure.

Have I made an error, or is there some other middleware I need to turn on? Or do I need a different approach altogether to enable CSRF with this scheme?

Samo
  • 8,202
  • 13
  • 58
  • 95

1 Answers1

7

I realize that this was a case of overthinking the problem. I really don't need Rails's forgery protection to do anything for me, or to check the value against the session, because the value of my token is already a cookie. Here's how I solved it:

First, the base controller sets the csrf cookie. This would be skipped for logout or any public endpoints, if there were any.

module V1
  class ApplicationController < ::ApplicationController
    include Concerns::Authentication
    include ActionController::RequestForgeryProtection

    after_action :set_csrf_cookie

    protected

    def set_csrf_cookie
      if current_user 
        cookies['X-CSRF-Token'] = form_authenticity_token
      end
    end
  end
end

Then my authenticated endpoints inherit from an AuthenticatedController that checks the auth token and the csrf token:

module V1
  class AuthenticatedController < ApplicationController
    before_action :authenticate!

    def authenticate!
      raise AuthenticationRequired unless current_user && csrf_token_valid?
    end

    rescue_from AuthenticationRequired do |e|
      render json: { message: 'Authentication Required', code: :authentication_required }, status: 403
    end

    rescue_from AuthTokenExpired do |e|
      render json: { message: 'Session Expired', code: :session_expired }, status: 403
    end

    private

    def csrf_token_valid?
      Rails.env != 'production' || request.headers['X-CSRF-Token'] === cookies['X-CSRF-Token']
    end
  end
end

Hope this helps someone else trying to use CSRF + cookies in a Rails 5 API!

Samo
  • 8,202
  • 13
  • 58
  • 95
  • Thanks for your work, can I ask you why this: `request.headers['X-CSRF-Token'] === cookies['X-CSRF-Token']`? –  Apr 30 '18 at 10:37
  • This is what makes sure that the token that the client placed in the header is the same as the token that the server wrote to the cookie. At the end of this request, the server will write a new, completely different token to the cookie, which will be used to compare against the header in the next request. – Samo May 07 '18 at 20:46
  • Yes, but why you need to verify it? Is your csrf cookie httpOnly? –  May 07 '18 at 20:51
  • 2
    No, it's a regular cookie. You need to verify it because the client should be taking the value from the cookie and placing it on the request header. That is the whole point of CSRF. If a user visits a different site (call it Site X) which makes a request to your site, the token will always be in the cookie. Site X does not know to place that token in the header, but *your* client does. So your client gets access by placing the token in the header, while Site X gets denied. Make sense? – Samo May 10 '18 at 12:43
  • Yes, it makes sense. But Rails already does it for you. Do you know? –  May 10 '18 at 20:51
  • You don't need to. –  May 10 '18 at 20:52
  • That is not completely true. Or at least, in the version of Rails I was running at the time, this did not happen automatically in API mode. Only in monolith mode. – Samo May 10 '18 at 21:07
  • How do you access the cookies key `X-CSRF-Token` from an SPA app? I used response headers, as I was not able to access the cookie. – Arup Rakshit May 24 '19 at 20:00
  • 1
    I didn't have to do that since `axios` handled that for me. However, maybe this helps? https://stackoverflow.com/questions/12840410/how-to-get-a-cookie-from-an-ajax-response – Samo May 28 '19 at 16:47
  • Could you do just `protect_from_forgery with: :exception` in your ApplicationController. This way you won't need this check `def csrf_token_valid?` – Igor Aug 03 '20 at 04:18
  • Depends on where that's checking for the token. At this point it's been a while but I don't think Rails was checking for it in `request.headers['X-CSRF-Token']` at the time. – Samo Aug 04 '20 at 05:08