CSRF protection in Rails
By Yangyu
- 6 minutes read - 1128 wordsCSRF(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
application.html.erb: the
csrf_meta_tags
would load CSRF token into the current web page:
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:
token is stored in the session, which is provided by the client side
the
verify_authenticity_token
would check, for the non-GET and non-HEAD method, 2 things:if the origin matches
if the token matches
token can be passed in 2 places:
from form data
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.