Rails CSRF Protection + Angular.js: protect_from_forgery makes me to log out on POST
Asked Answered
B

8

131

If the protect_from_forgery option is mentioned in application_controller, then I can log in and perform any GET requests, but on very first POST request Rails resets the session, which logs me out.

I turned the protect_from_forgery option off temporarily, but would like to use it with Angular.js. Is there some way to do that?

Benzene answered 6/2, 2013 at 16:41 Comment(1)
See if this helps any, its about setting HTTP headers #14183525Tarbox
G
278

I think reading CSRF-value from DOM is not a good solution, it's just a workaround.

Here is a document form angularJS official website http://docs.angularjs.org/api/ng.$http :

Since only JavaScript that runs on your domain could read the cookie, your server can be assured that the XHR came from JavaScript running on your domain.

To take advantage of this (CSRF Protection), your server needs to set a token in a JavaScript readable session cookie called XSRF-TOKEN on first HTTP GET request. On subsequent non-GET requests the server can verify that the cookie matches X-XSRF-TOKEN HTTP header

Here is my solution based on those instructions:

First, set the cookie:

# app/controllers/application_controller.rb

# Turn on request forgery protection
protect_from_forgery

after_action :set_csrf_cookie

def set_csrf_cookie
  cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
end

Then, we should verify the token on every non-GET request.
Since Rails has already built with the similar method, we can just simply override it to append our logic:

# app/controllers/application_controller.rb

protected
  
  # In Rails 4.2 and above
  def verified_request?
    super || valid_authenticity_token?(session, request.headers['X-XSRF-TOKEN'])
  end

  # In Rails 4.1 and below
  def verified_request?
    super || form_authenticity_token == request.headers['X-XSRF-TOKEN']
  end
Genesia answered 2/4, 2013 at 10:30 Comment(20)
I like this technique, as you don't have to modify any client-side code.Mignonmignonette
Thank you. I like this. It is worth to mention that protect_from_forgery must be still present to make this code work.Benzene
How does this solution preserve the usefulness of CSRF protection? By setting the cookie, the marked user's browser will send that cookie on all subsequent requests including cross-site requests. I could set up a malicious third party site that send a malicious request and the user's browser would send 'XSRF-TOKEN' to the server. It seems like this solution is tantamount to turning off CSRF protection altogether.Possessory
From the Angular docs: "Since only JavaScript that runs on your domain could read the cookie, your server can be assured that the XHR came from JavaScript running on your domain." @StevenXu - How would the third party site read the cookie?Fanny
@JimmyBaker: yes, you're right. I've reviewed the documentation. The approach is conceptually sound. I confused the setting of the cookie with the validation, not realizing that Angular the framework was setting a custom header based on the value of the cookie!Possessory
just come across this solution and it's great. I wonder why other solutions still use client-side code though.Botha
not sure if it's a change in AngularJS, but the header is X-XSRF-TOKEN (notice the dashes instead of the underscores). Anyway both this method and the one below works like a charm. THANKS!Missive
@Kubee Yes, this's confused, and even the Rails source is using dashe, but in my solution only the underscore works. github.com/rails/rails/blob/…Genesia
should be: request.headers['X-XSRF-TOKEN'] with dashes instead of underscores. I submitted an edit but not peer reviewed yetTonguelashing
@Tonguelashing I tested, request.headers['X_XSRF_TOKEN'] works while request.headers['X-XSRF-TOKEN'] doesn'tGenesia
Worked for me when I used X-XSRF-TOKEN and it broke when using underscore. Possibly version issue?Yonyona
For me, it is "HTTP_X_XSRF_TOKEN". You can pry on the headers from within "verified_request?" definition before the Boolean expression.Corrinnecorrival
shouldn't we check that the request is made via xhr (thus can be trusted as from same domain) before running the after filter and set the csrf token?Sisco
form_authenticity_token generates new values on each call in Rails 4.2, so this doesn't appear to work anymore.Rinna
This does break client side code for me on rails 4.2 even with the edit above. Adding/deleting entries hit the database but I'm getting routing error when responding with json through an ajax request. Sadly the "workaround" in the below answer works.Cake
Does the cookie need to be set as httponly per blog.codinghorror.com/protecting-your-cookies-httponly? If so, how would you do that?Mycetozoan
I just added automated tests against Rails versions from 3.0 to 4.2 in my angular_rails_csrf gem that uses this pattern. A few things: @HungYuHei: Using dashes like X-XSRF-TOKEN seems to work fine. @Sinbadsoft.com: The call to super in verified_request? handles checking for xhr?. @Dave: The new values on each request in 4.2 are just "masked" and they are handled by valid_authenticity_token?. @Jonathon Mui: The cookie is not HttpOnly because it needs to be read by javascript and injected into a header for this pattern to work.Semitics
Though the above way works , i prefer @Semitics way [3 rd answer]Introspection
For me (Rails 4.2.6), the verified request method had to read from cookies variable: super || valid_authenticity_token?(session, cookies['XSRF-TOKEN'])Thagard
sorry but It sounds to me that CSRF cookie would not solve this scenario: guides.rubyonrails.org/… can you expand on that ?Katy
M
78

If you're using the default Rails CSRF protection (<%= csrf_meta_tags %>), you can configure your Angular module like this:

myAngularApp.config ["$httpProvider", ($httpProvider) ->
  $httpProvider.defaults.headers.common['X-CSRF-Token'] = $('meta[name=csrf-token]').attr('content')
]

Or, if you're not using CoffeeScript (what!?):

myAngularApp.config([
  "$httpProvider", function($httpProvider) {
    $httpProvider.defaults.headers.common['X-CSRF-Token'] = $('meta[name=csrf-token]').attr('content');
  }
]);

If you prefer, you can send the header only on non-GET requests with something like the following:

myAngularApp.config ["$httpProvider", ($httpProvider) ->
  csrfToken = $('meta[name=csrf-token]').attr('content')
  $httpProvider.defaults.headers.post['X-CSRF-Token'] = csrfToken
  $httpProvider.defaults.headers.put['X-CSRF-Token'] = csrfToken
  $httpProvider.defaults.headers.patch['X-CSRF-Token'] = csrfToken
  $httpProvider.defaults.headers.delete['X-CSRF-Token'] = csrfToken
]

Also, be sure to check out HungYuHei's answer, which covers all the bases on the server rather than the client.

Mignonmignonette answered 6/2, 2013 at 17:30 Comment(9)
Let me explain. The base document is a plain HTML, not .erb therefore I cannot use <%= csrf_meta_tags %>. I thought that there should be enough to mention protect_from_forgery only. What to do? The base document must be a plain HTML (I am here not the one who chooses).Benzene
When you use protect_from_forgery what you're saying is "when my JavaScript code makes Ajax requests, I promise to send an X-CSRF-Token in the header that corresponds to the current CSRF token." In order to get this token, Rails injects it into the DOM with <%= csrf_meta_token %> and get gets the contents of the meta tag with jQuery whenever it makes Ajax requests (the default Rails 3 UJS driver does this for you). If you're not using ERB, there's no way to get the current token from Rails into the page and/or the JavaScript--and thus you cannot use protect_from_forgery in this manner.Mignonmignonette
Thank you for explanation. What I thought that in a classic server-side application the client side receives csrf_meta_tags each time the server generates a response, and each time these tags are different from previous ones. So, these tags are unique for each request. The question is: how the application receives these tags for an AJAX request (without angular)? I used protect_from_forgery with jQuery POST requests, never bothered myself with getting this CSRF token, and it worked. How?Benzene
The Rails UJS driver uses jQuery.ajaxPrefilter as shown here: github.com/indirect/jquery-rails/blob/c1eb6ae/vendor/assets/… You can peruse this file and see all the hoops Rails jumps through to make it work pretty much without having to worry about it.Mignonmignonette
@BrandonTilley wouldn't it make sense do only do this on put and post instead of on common? From the rails security guide: The solution to this is including a security token in non-GET requestsIey
@denbuzze I don't see any reason not to enumerate them if you'd like. Our app uses POST, DELETE and PATCH. I'm not sure if sending the token over GET is a security issue or not, but if it's not, setting them with common is a quick way to cover all your bases.Mignonmignonette
@Brandon Tilley: sorry, I have to unmark your answer because HungYuHei's reply is more ready to practical use in my situation. If Stackoverflow allows, I would mark your replies both as best answers, because they complement each other.Benzene
How to access csrfToken without using jquery $('meta[name=csrf-token]').attr('content')? Since $cookies which carries the XSRF-TOKEN isn't available in the config block, and updating headers in run does no good.Barleycorn
I'm having trouble with the last one. $httpProvider.defaults.headers.delete['X-CSRF-Token'] = csrfToken throws this error: $injector:modulerr] Failed to instantiate module AngulaRails due to: undefined is not an object (evaluating '$httpProvider.defaults.headers["delete"]['X-CSRF-Token'] = csrfToken')Cake
S
29

The angular_rails_csrf gem automatically adds support for the pattern described in HungYuHei's answer to all your controllers:

# Gemfile
gem 'angular_rails_csrf'
Semitics answered 13/12, 2013 at 17:18 Comment(3)
any idea how you should configure your application controller and other csrf/forgery-related settings, to use angular_rails_csrf correctly?Counterscarp
At the time of this comment the angular_rails_csrf gem doesn't work with Rails 5. However, configuring Angular request headers with the value from the CSRF meta tag works!Caernarvonshire
There is a new release of the gem, which supports Rails 5.Semitics
C
4

The answer that merges all previous answers and it relies that you are using Devise authentication gem.

First of all, add the gem:

gem 'angular_rails_csrf'

Next, add rescue_from block into application_controller.rb:

protect_from_forgery with: :exception

rescue_from ActionController::InvalidAuthenticityToken do |exception|
  cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
  render text: 'Invalid authenticity token', status: :unprocessable_entity
end

And the finally, add the interceptor module to you angular app.

# coffee script
app.factory 'csrfInterceptor', ['$q', '$injector', ($q, $injector) ->
  responseError: (rejection) ->
    if rejection.status == 422 && rejection.data == 'Invalid authenticity token'
        deferred = $q.defer()

        successCallback = (resp) ->
          deferred.resolve(resp)
        errorCallback = (resp) ->
          deferred.reject(resp)

        $http = $http || $injector.get('$http')
        $http(rejection.config).then(successCallback, errorCallback)
        return deferred.promise

    $q.reject(rejection)
]

app.config ($httpProvider) ->
  $httpProvider.interceptors.unshift('csrfInterceptor')
Carp answered 28/5, 2014 at 15:53 Comment(2)
Why are you injecting $injector instead of just directly injecting $http?Phenocryst
This works, but only think I added is check if request already repeated. When it was repeated we do not send again since it will loop forever.Schacker
A
1

I saw the other answers and thought they were great and well thought out. I got my rails app working though with what I thought was a simpler solution so I thought I'd share. My rails app came with this defaulted in it,

class ApplicationController < ActionController::Base
  # Prevent CSRF attacks by raising an exception.
  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :exception
end

I read the comments and it seemed like that is what I want to use angular and avoid the csrf error. I changed it to this,

class ApplicationController < ActionController::Base
  # Prevent CSRF attacks by raising an exception.
  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :null_session
end

And now it works! I don't see any reason why this shouldn't work, but I'd love to hear some insight from other posters.

Arlettearley answered 9/9, 2013 at 14:39 Comment(2)
this will cause issues if you are trying to use rails 'sessions' since it will be set to nil if it fails the forgery test, which would be always, since you're not sending the csrf-token from the client side.Lashawna
But if you're not using Rails sessions all is well; thank you! I've been struggling to find the cleanest solution to this.Straka
A
1

I've used the content from HungYuHei's answer in my application. I found that I was dealing with a few additional issues however, some because of my use of Devise for authentication, and some because of the default that I got with my application:

protect_from_forgery with: :exception

I note the related stack overflow question and the answers there, and I wrote a much more verbose blog post that summarises the various considerations. The portions of that solution that are relevant here are, in the application controller:

  protect_from_forgery with: :exception

  after_filter :set_csrf_cookie_for_ng

  def set_csrf_cookie_for_ng
    cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
  end

  rescue_from ActionController::InvalidAuthenticityToken do |exception|
    cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
    render :error => 'Invalid authenticity token', {:status => :unprocessable_entity} 
  end

protected
  def verified_request?
    super || form_authenticity_token == request.headers['X-XSRF-TOKEN']
  end
Acromion answered 17/4, 2014 at 14:6 Comment(0)
S
1

I found a very quick hack to this. All I had to do is the following:

a. In my view, I initialize a $scope variable which contains the token, let's say before the form, or even better at controller initialization:

<div ng-controller="MyCtrl" ng-init="authenticity_token = '<%= form_authenticity_token %>'">

b. In my AngularJS controller, before saving my new entry, I add the token to the hash:

$scope.addEntry = ->
    $scope.newEntry.authenticity_token = $scope.authenticity_token 
    entry = Entry.save($scope.newEntry)
    $scope.entries.push(entry)
    $scope.newEntry = {}

Nothing more needs to be done.

Supplicate answered 30/12, 2014 at 22:21 Comment(0)
B
0
 angular
  .module('corsInterceptor', ['ngCookies'])
  .factory(
    'corsInterceptor',
    function ($cookies) {
      return {
        request: function(config) {
          config.headers["X-XSRF-TOKEN"] = $cookies.get('XSRF-TOKEN');
          return config;
        }
      };
    }
  );

It's working on angularjs side!

Balustrade answered 16/2, 2017 at 15:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.