CSRF(Cross Site Request Forgery) is an attack that forces an end user to execute unwanted actions on a web application in which he/she is currently authenticated.

In this post, I’ll explore, in the source code level, how Rails protect itself from CSRF. It has two checks: based on token, and also the origin header.

We’ll first look at how rails put the token into the page, then see how the token is checked.

Include Token in Page

There’re two places to insert token in page: meta tag, and form.

Token in Meta Tag

Genreral View

csrf_meta_tags would call form_authenticity_token to generate the corresponding token, here’s the logic:

  • in application.html.erb:

        <head>
          <title>RailsCsrfDefense</title>
          <%= csrf_meta_tags %>
          ... ...
        </head>
    

Internal

  • in rails/actionview/lib/action_view/helpers/csrf_helper.rb:13

      def csrf_meta_tags
        if protect_against_forgery?
          [
            tag('meta', :name => 'csrf-param', :content => request_forgery_protection_token),
            tag('meta', :name => 'csrf-token', :content => form_authenticity_token)
          ].join("\n").html_safe
        end
      end
    
  • in rails/actionpack/lib/action_controller/metal/request_forgery_protection.rb:284

        # Sets the token value for the current session.
        def form_authenticity_token(form_options: {})
          masked_authenticity_token(session, form_options: form_options)
        end
    
        # Creates a masked version of the authenticity token that varies
        # on each request. The masking is used to mitigate SSL attacks
        # like BREACH.
        def masked_authenticity_token(session, form_options: {})
          action, method = form_options.values_at(:action, :method)
    
          raw_token = if per_form_csrf_tokens && action && method
            action_path = normalize_action_path(action)
            per_form_csrf_token(session, action_path, method)
          else
            real_csrf_token(session)
          end
    
          one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
          encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
          masked_token = one_time_pad + encrypted_csrf_token
          Base64.strict_encode64(masked_token)
        end
    

where the real_csrf_token would generate token randomly, and stored in the current session:

  • in rails/actionpack/lib/action_controller/metal/request_forgery_protection.rb:367

        def real_csrf_token(session)
          session[:_csrf_token] ||= SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH)
          Base64.strict_decode64(session[:_csrf_token])
        end
    

Because it is stored in the session, it doesn’t change over time. This might leads to a “reply attack”: sniff the network traffic, get the token, reply the attack.

The one_time_pad is generated to mask the token, to overcome the breach attack, which relies on some reflected content from the original request to guess the HTTPS secret.

Token in the form

Further more, as we can see in the picture above, the authenticity_token is also included in each form, with the same value as the one in the header.

General View

By default, the form_for method would use the same token as the one in meta-tag:

  • in actionview/lib/action_view/helpers/form_helper.rb:428

        def form_for(record, options = {}, &block)
          # ... ...
          
          html_options = options[:html] ||= {}
    
          # ... ...
          
          html_options[:authenticity_token] = options.delete(:authenticity_token)
          
          # ... ...
          
          form_tag_with_body(html_options, output)
        end
    

If you want to implement your own form-based CSRF protection, or you want this form to submit to an external URL, you can pass in a token which is generated by you:

<%= form_for @invoice, url: external_url, authenticity_token: 'external_token' do |f| %>
   ...
 <% end %>

you can also put authenticity_token: false if you don’t want the token.

Internal

Internally, the form_for would call the form_tag_with_body, which would further call form_tag_html:

  • in actionview/lib/action_view/helpers/form_tag_helper.rb:890

          def form_tag_with_body(html_options, content)
            output = form_tag_html(html_options)
            output << content
            output.safe_concat("</form>")
          end
            
          def form_tag_html(html_options)
            extra_tags = extra_tags_for_form(html_options)
            tag(:form, html_options, true) + extra_tags
          end
    

The actual authenticity_token would be include in extra_tags_for_form:

  • in actionview/lib/action_view/helpers/form_tag_helper.rb:856

          def extra_tags_for_form(html_options)
            authenticity_token = html_options.delete("authenticity_token")
            method = html_options.delete("method").to_s.downcase
    
            method_tag = case method
              when 'get'
                html_options["method"] = "get"
                ''
              when 'post', ''
                html_options["method"] = "post"
                token_tag(authenticity_token, form_options: {
                  action: html_options["action"],
                  method: "post"
                })
              else
                html_options["method"] = "post"
                method_tag(method) + token_tag(authenticity_token, form_options: {
                  action: html_options["action"],
                  method: method
                })
            end
            
            # ...
          end
          
    

As we can see, the actual authenticity_token would be include for any HTTP method other than GET. (i.e.: POST / PATCH / PUT / DELETE)

Last but not least, the actual token would be generated by form_authenticity_token in the token_tag method, which is also used by the csrf_meta_tags method.

  • in actionview/lib/action_view/helpers/url_helper.rb:589

        def token_tag(token=nil, form_options: {})
          if token != false && protect_against_forgery?
            token ||= form_authenticity_token(form_options: form_options)
            tag(:input, type: "hidden", name: request_forgery_protection_token.to_s, value: token)
          else
            ''.freeze
          end
        end
    

Token Validation

Setup

In the controller, call protect_from_forgery with settings, where settings can be:

  • :exception - Raises ActionController::InvalidAuthenticityToken exception.
  • :reset_session - Resets the session.
  • :null_session - Provides an empty session during request but doesn’t reset it completely. Used as default if :with option is not specified.

Internel

  • in actionpack/lib/action_controller/metal/request_forgery_protection.rb:122
      def protect_from_forgery(options = {})
        options = options.reverse_merge(prepend: false)

        self.forgery_protection_strategy = protection_method_class(options[:with] || :null_session)
        self.request_forgery_protection_token ||= :authenticity_token
        before_action :verify_authenticity_token, options
        append_after_action :verify_same_origin_request
      end

The protect_from_forgery would add the following filters into the controller:

  • verify_authenticity_token
  • verify_same_origin_request

verify_authenticity_token

  • in actionpack/lib/action_controller/metal/request_forgery_protection.rb:211

        def verify_authenticity_token
          # no need to verify if it is GET request
          mark_for_same_origin_verification!
    
          if !verified_request?
            if logger && log_warning_on_csrf_failure
              logger.warn "Can't verify CSRF token authenticity."
            end
            handle_unverified_request
          end
        end
        
        # Line 266
        def verified_request?
          !protect_against_forgery? || request.get? || request.head? ||
            (valid_request_origin? && any_authenticity_token_valid?)
        end
        
        # Line 399
        def valid_request_origin?
          if forgery_protection_origin_check
            # We accept blank origin headers because some user agents don't send it.
            request.origin.nil? || request.origin == request.base_url
          else
            true
          end
        end
        
        # Line 272
        def any_authenticity_token_valid?
          request_authenticity_tokens.any? do |token|
            valid_authenticity_token?(session, token)
          end
        end
        
      
        # Line 308
        
        # Checks the client's masked token to see if it matches the
        # session token. Essentially the inverse of
        # +masked_authenticity_token+.
        def valid_authenticity_token?(session, encoded_masked_token)
          if encoded_masked_token.nil? || encoded_masked_token.empty? || !encoded_masked_token.is_a?(String)
            return false
          end
    
          begin
            masked_token = Base64.strict_decode64(encoded_masked_token)
          rescue ArgumentError # encoded_masked_token is invalid Base64
            return false
          end
    
          # See if it's actually a masked token or not. In order to
          # deploy this code, we should be able to handle any unmasked
          # tokens that we've issued without error.
    
          if masked_token.length == AUTHENTICITY_TOKEN_LENGTH
            # This is actually an unmasked token. This is expected if
            # you have just upgraded to masked tokens, but should stop
            # happening shortly after installing this gem
            compare_with_real_token masked_token, session
    
          elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
            csrf_token = unmask_token(masked_token)
    
            compare_with_real_token(csrf_token, session) ||
              valid_per_form_csrf_token?(csrf_token, session)
          else
            false # Token is malformed
          end
        end
        
        # Line 350
        def compare_with_real_token(token, session)
          ActiveSupport::SecurityUtils.secure_compare(token, real_csrf_token(session))
        end
    

The chunk of code above is how Rails validate CSRF token:

  1. token is stored in the session, which is provided by the client side
  2. the verify_authenticity_token would check, for the non-GET and non-HEAD method, 2 things:
  3. if the origin matches
  4. if the token matches
  5. token can be passed in 2 places:
  6. from form data
  7. from HTTP header (verify_authenticity_token)

     # actionpack/lib/action_controller/metal/request_forgery_protection.rb:280
     def request_authenticity_tokens
       [form_authenticity_param, request.x_csrf_token]
     end
    

verify_same_origin_request

After the page is rendered, another verify_same_origin_request verification would be done. This is only done upon the GET request.

  • in actionpack/lib/action_controller/metal/request_forgery_protection.rb:236

        def verify_same_origin_request
          if marked_for_same_origin_verification? && non_xhr_javascript_response?
            logger.warn CROSS_ORIGIN_JAVASCRIPT_WARNING if logger
            raise ActionController::InvalidCrossOriginRequest, CROSS_ORIGIN_JAVASCRIPT_WARNING
          end
        end
        
        # Line 255
        # Check for cross-origin JavaScript responses.
        def non_xhr_javascript_response?
          content_type =~ %r(\Atext/javascript) && !request.xhr?
        end
    

marked_for_same_origin_verification? would only be marked when it’s a GET request, and non_xhr_javascript_response? would check check if the response type is javascript, and the request is non-XHR. It usually means JSONP request.

I think Rails discoverage the use of JSONP request, because it has potential issue of CSRF. An example endpoint is /books/jsonp – it’s serving a JSONP endpoint, and blocked by Rails by default.