diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 8e2751aedb358..2d91ba1f8ba65 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,23 @@ +* Accept base64_urlsafe CSRF tokens to make forward compatible. + + Base64 strict-encoded CSRF tokens are not inherently websafe, which makes + them difficult to deal with. For example, the common practice of sending + the CSRF token to a browser in a client-readable cookie does not work properly + out of the box: the value has to be url-encoded and decoded to survive transport. + + In Rails 6.1, we generate Base64 urlsafe-encoded CSRF tokens, which are inherently + safe to transport. Validation accepts both urlsafe tokens, and strict-encoded + tokens for backwards compatibility. + + In Rails 5.2.5, the CSRF token format is accidentally changed to urlsafe-encoded. + If you upgrade apps from 5.2.5, set the config `urlsafe_csrf_tokens = true`. + + ```ruby + Rails.application.config.action_controller.urlsafe_csrf_tokens = true + ``` + + *Scott Blum*, *Étienne Barrié* + * Signed and encrypted cookies can now store `false` as their value when `action_dispatch.use_cookies_with_metadata` is enabled. diff --git a/actionpack/lib/action_controller/metal/request_forgery_protection.rb b/actionpack/lib/action_controller/metal/request_forgery_protection.rb index 8123170533346..3c7faaf1d1c94 100644 --- a/actionpack/lib/action_controller/metal/request_forgery_protection.rb +++ b/actionpack/lib/action_controller/metal/request_forgery_protection.rb @@ -90,6 +90,10 @@ module RequestForgeryProtection config_accessor :default_protect_from_forgery self.default_protect_from_forgery = false + # Controls whether URL-safe CSRF tokens are generated. + config_accessor :urlsafe_csrf_tokens, instance_writer: false + self.urlsafe_csrf_tokens = false + helper_method :form_authenticity_token helper_method :protect_against_forgery? end @@ -329,7 +333,7 @@ def valid_authenticity_token?(session, encoded_masked_token) # :doc: end begin - masked_token = Base64.strict_decode64(encoded_masked_token) + masked_token = decode_csrf_token(encoded_masked_token) rescue ArgumentError # encoded_masked_token is invalid Base64 return false end @@ -367,7 +371,7 @@ def mask_token(raw_token) # :doc: 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) + encode_csrf_token(masked_token) end def compare_with_real_token(token, session) # :doc: @@ -393,8 +397,8 @@ def valid_per_form_csrf_token?(token, session) # :doc: end def real_csrf_token(session) # :doc: - session[:_csrf_token] ||= SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH) - Base64.strict_decode64(session[:_csrf_token]) + session[:_csrf_token] ||= generate_csrf_token + decode_csrf_token(session[:_csrf_token]) end def per_form_csrf_token(session, action_path, method) # :doc: @@ -462,5 +466,33 @@ def normalize_action_path(action_path) # :doc: uri = URI.parse(action_path) uri.path.chomp("/") end + + def generate_csrf_token # :nodoc: + if urlsafe_csrf_tokens + SecureRandom.urlsafe_base64(AUTHENTICITY_TOKEN_LENGTH, padding: false) + else + SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH) + end + end + + def encode_csrf_token(csrf_token) # :nodoc: + if urlsafe_csrf_tokens + Base64.urlsafe_encode64(csrf_token, padding: false) + else + Base64.strict_encode64(csrf_token) + end + end + + def decode_csrf_token(encoded_csrf_token) # :nodoc: + if urlsafe_csrf_tokens + Base64.urlsafe_decode64(encoded_csrf_token) + else + begin + Base64.strict_decode64(encoded_csrf_token) + rescue ArgumentError + Base64.urlsafe_decode64(encoded_csrf_token) + end + end + end end end diff --git a/actionpack/test/controller/request_forgery_protection_test.rb b/actionpack/test/controller/request_forgery_protection_test.rb index 10de9bcc3e533..1ad48b5cf69f3 100644 --- a/actionpack/test/controller/request_forgery_protection_test.rb +++ b/actionpack/test/controller/request_forgery_protection_test.rb @@ -175,12 +175,15 @@ class SkipProtectionController < ActionController::Base # common test methods module RequestForgeryProtectionTests def setup - @token = Base64.strict_encode64("railstestrailstestrailstestrails") + @old_urlsafe_csrf_tokens = ActionController::Base.urlsafe_csrf_tokens + ActionController::Base.urlsafe_csrf_tokens = true + @token = Base64.urlsafe_encode64("railstestrailstestrailstestrails") @old_request_forgery_protection_token = ActionController::Base.request_forgery_protection_token ActionController::Base.request_forgery_protection_token = :custom_authenticity_token end def teardown + ActionController::Base.urlsafe_csrf_tokens = @old_urlsafe_csrf_tokens ActionController::Base.request_forgery_protection_token = @old_request_forgery_protection_token end @@ -377,6 +380,28 @@ def test_should_allow_post_with_token end end + def test_should_allow_post_with_strict_encoded_token + token_length = (ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH * 4.0 / 3).ceil + token_including_url_unsafe_chars = "+/".ljust(token_length, "A") + session[:_csrf_token] = token_including_url_unsafe_chars + @controller.stub :form_authenticity_token, token_including_url_unsafe_chars do + assert_not_blocked { post :index, params: { custom_authenticity_token: token_including_url_unsafe_chars } } + end + end + + def test_should_allow_post_with_urlsafe_token_when_migrating + config_before = ActionController::Base.urlsafe_csrf_tokens + ActionController::Base.urlsafe_csrf_tokens = false + token_length = (ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH * 4.0 / 3).ceil + token_including_url_safe_chars = "-_".ljust(token_length, "A") + session[:_csrf_token] = token_including_url_safe_chars + @controller.stub :form_authenticity_token, token_including_url_safe_chars do + assert_not_blocked { post :index, params: { custom_authenticity_token: token_including_url_safe_chars } } + end + ensure + ActionController::Base.urlsafe_csrf_tokens = config_before + end + def test_should_allow_patch_with_token session[:_csrf_token] = @token @controller.stub :form_authenticity_token, @token do @@ -735,21 +760,21 @@ def setup end def test_should_not_render_form_with_token_tag - SecureRandom.stub :base64, @token do + SecureRandom.stub :urlsafe_base64, @token do get :index assert_select "form>div>input[name=?][value=?]", "authenticity_token", @token, false end end def test_should_not_render_button_to_with_token_tag - SecureRandom.stub :base64, @token do + SecureRandom.stub :urlsafe_base64, @token do get :show_button assert_select "form>div>input[name=?][value=?]", "authenticity_token", @token, false end end def test_should_allow_all_methods_without_token - SecureRandom.stub :base64, @token do + SecureRandom.stub :urlsafe_base64, @token do [:post, :patch, :put, :delete].each do |method| assert_nothing_raised { send(method, :index) } end @@ -757,7 +782,7 @@ def test_should_allow_all_methods_without_token end test "should not emit a csrf-token meta tag" do - SecureRandom.stub :base64, @token do + SecureRandom.stub :urlsafe_base64, @token do get :meta assert_predicate @response.body, :blank? end @@ -769,7 +794,7 @@ def setup super @old_logger = ActionController::Base.logger @logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new - @token = Base64.strict_encode64(SecureRandom.random_bytes(32)) + @token = Base64.urlsafe_encode64(SecureRandom.random_bytes(32)) @old_request_forgery_protection_token = ActionController::Base.request_forgery_protection_token ActionController::Base.request_forgery_protection_token = @token end @@ -1029,7 +1054,7 @@ def assert_presence_and_fetch_form_csrf_token end def assert_matches_session_token_on_server(form_token, method = "post") - actual = @controller.send(:unmask_token, Base64.strict_decode64(form_token)) + actual = @controller.send(:unmask_token, Base64.urlsafe_decode64(form_token)) expected = @controller.send(:per_form_csrf_token, session, "/per_form_tokens/post_one", method) assert_equal expected, actual end diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 61da951ab8805..29750bc97701d 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -486,6 +486,8 @@ The schema dumper adds two additional configuration options: * `config.action_controller.default_protect_from_forgery` determines whether forgery protection is added on `ActionController:Base`. This is false by default. +* `config.action_controller.urlsafe_csrf_tokens` configures whether generated CSRF tokens are URL-safe. Defaults to `false`. + * `config.action_controller.relative_url_root` can be used to tell Rails that you are [deploying to a subdirectory](configuring.html#deploy-to-a-subdirectory-relative-url-root). The default is `ENV['RAILS_RELATIVE_URL_ROOT']`. * `config.action_controller.permit_all_parameters` sets all the parameters for mass assignment to be permitted by default. The default value is `false`.