Samo
Samo

Reputation: 8240

How to enable CSRF in Rails 5 API mode

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?

Upvotes: 4

Views: 3240

Answers (1)

Samo
Samo

Reputation: 8240

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!

Upvotes: 7

Related Questions