Skip to content

Commit eaf001d

Browse files
committed
Merge pull request #41806 from kamipo/backport_39076_to_6-0-stable
[6-0-stable] Backport Upgrade-safe URL-safe CSRF tokens #39076
1 parent 767acf6 commit eaf001d

File tree

4 files changed

+91
-11
lines changed

4 files changed

+91
-11
lines changed

actionpack/CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
* Accept base64_urlsafe CSRF tokens to make forward compatible.
2+
3+
Base64 strict-encoded CSRF tokens are not inherently websafe, which makes
4+
them difficult to deal with. For example, the common practice of sending
5+
the CSRF token to a browser in a client-readable cookie does not work properly
6+
out of the box: the value has to be url-encoded and decoded to survive transport.
7+
8+
In Rails 6.1, we generate Base64 urlsafe-encoded CSRF tokens, which are inherently
9+
safe to transport. Validation accepts both urlsafe tokens, and strict-encoded
10+
tokens for backwards compatibility.
11+
12+
In Rails 5.2.5, the CSRF token format is accidentally changed to urlsafe-encoded.
13+
If you upgrade apps from 5.2.5, set the config `urlsafe_csrf_tokens = true`.
14+
15+
```ruby
16+
Rails.application.config.action_controller.urlsafe_csrf_tokens = true
17+
```
18+
19+
*Scott Blum*, *Étienne Barrié*
20+
21+
122
## Rails 5.2.5 (March 26, 2021) ##
223

324
* No changes.

actionpack/lib/action_controller/metal/request_forgery_protection.rb

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ module RequestForgeryProtection
9292
config_accessor :default_protect_from_forgery
9393
self.default_protect_from_forgery = false
9494

95+
# Controls whether URL-safe CSRF tokens are generated.
96+
config_accessor :urlsafe_csrf_tokens, instance_writer: false
97+
self.urlsafe_csrf_tokens = false
98+
9599
helper_method :form_authenticity_token
96100
helper_method :protect_against_forgery?
97101
end
@@ -333,7 +337,7 @@ def valid_authenticity_token?(session, encoded_masked_token) # :doc:
333337
end
334338

335339
begin
336-
masked_token = Base64.strict_decode64(encoded_masked_token)
340+
masked_token = decode_csrf_token(encoded_masked_token)
337341
rescue ArgumentError # encoded_masked_token is invalid Base64
338342
return false
339343
end
@@ -371,7 +375,7 @@ def mask_token(raw_token) # :doc:
371375
one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
372376
encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
373377
masked_token = one_time_pad + encrypted_csrf_token
374-
Base64.strict_encode64(masked_token)
378+
encode_csrf_token(masked_token)
375379
end
376380

377381
def compare_with_real_token(token, session) # :doc:
@@ -397,8 +401,8 @@ def valid_per_form_csrf_token?(token, session) # :doc:
397401
end
398402

399403
def real_csrf_token(session) # :doc:
400-
session[:_csrf_token] ||= SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH)
401-
Base64.strict_decode64(session[:_csrf_token])
404+
session[:_csrf_token] ||= generate_csrf_token
405+
decode_csrf_token(session[:_csrf_token])
402406
end
403407

404408
def per_form_csrf_token(session, action_path, method) # :doc:
@@ -461,5 +465,33 @@ def normalize_action_path(action_path) # :doc:
461465
uri = URI.parse(action_path)
462466
uri.path.chomp("/")
463467
end
468+
469+
def generate_csrf_token # :nodoc:
470+
if urlsafe_csrf_tokens
471+
SecureRandom.urlsafe_base64(AUTHENTICITY_TOKEN_LENGTH, padding: false)
472+
else
473+
SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH)
474+
end
475+
end
476+
477+
def encode_csrf_token(csrf_token) # :nodoc:
478+
if urlsafe_csrf_tokens
479+
Base64.urlsafe_encode64(csrf_token, padding: false)
480+
else
481+
Base64.strict_encode64(csrf_token)
482+
end
483+
end
484+
485+
def decode_csrf_token(encoded_csrf_token) # :nodoc:
486+
if urlsafe_csrf_tokens
487+
Base64.urlsafe_decode64(encoded_csrf_token)
488+
else
489+
begin
490+
Base64.strict_decode64(encoded_csrf_token)
491+
rescue ArgumentError
492+
Base64.urlsafe_decode64(encoded_csrf_token)
493+
end
494+
end
495+
end
464496
end
465497
end

actionpack/test/controller/request_forgery_protection_test.rb

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,15 @@ class SkipProtectionController < ActionController::Base
176176
# common test methods
177177
module RequestForgeryProtectionTests
178178
def setup
179-
@token = Base64.strict_encode64("railstestrailstestrailstestrails")
179+
@old_urlsafe_csrf_tokens = ActionController::Base.urlsafe_csrf_tokens
180+
ActionController::Base.urlsafe_csrf_tokens = true
181+
@token = Base64.urlsafe_encode64("railstestrailstestrailstestrails")
180182
@old_request_forgery_protection_token = ActionController::Base.request_forgery_protection_token
181183
ActionController::Base.request_forgery_protection_token = :custom_authenticity_token
182184
end
183185

184186
def teardown
187+
ActionController::Base.urlsafe_csrf_tokens = @old_urlsafe_csrf_tokens
185188
ActionController::Base.request_forgery_protection_token = @old_request_forgery_protection_token
186189
end
187190

@@ -378,6 +381,28 @@ def test_should_allow_post_with_token
378381
end
379382
end
380383

384+
def test_should_allow_post_with_strict_encoded_token
385+
token_length = (ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH * 4.0 / 3).ceil
386+
token_including_url_unsafe_chars = "+/".ljust(token_length, "A")
387+
session[:_csrf_token] = token_including_url_unsafe_chars
388+
@controller.stub :form_authenticity_token, token_including_url_unsafe_chars do
389+
assert_not_blocked { post :index, params: { custom_authenticity_token: token_including_url_unsafe_chars } }
390+
end
391+
end
392+
393+
def test_should_allow_post_with_urlsafe_token_when_migrating
394+
config_before = ActionController::Base.urlsafe_csrf_tokens
395+
ActionController::Base.urlsafe_csrf_tokens = false
396+
token_length = (ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH * 4.0 / 3).ceil
397+
token_including_url_safe_chars = "-_".ljust(token_length, "A")
398+
session[:_csrf_token] = token_including_url_safe_chars
399+
@controller.stub :form_authenticity_token, token_including_url_safe_chars do
400+
assert_not_blocked { post :index, params: { custom_authenticity_token: token_including_url_safe_chars } }
401+
end
402+
ensure
403+
ActionController::Base.urlsafe_csrf_tokens = config_before
404+
end
405+
381406
def test_should_allow_patch_with_token
382407
session[:_csrf_token] = @token
383408
@controller.stub :form_authenticity_token, @token do
@@ -722,29 +747,29 @@ def setup
722747
end
723748

724749
def test_should_not_render_form_with_token_tag
725-
SecureRandom.stub :base64, @token do
750+
SecureRandom.stub :urlsafe_base64, @token do
726751
get :index
727752
assert_select "form>div>input[name=?][value=?]", "authenticity_token", @token, false
728753
end
729754
end
730755

731756
def test_should_not_render_button_to_with_token_tag
732-
SecureRandom.stub :base64, @token do
757+
SecureRandom.stub :urlsafe_base64, @token do
733758
get :show_button
734759
assert_select "form>div>input[name=?][value=?]", "authenticity_token", @token, false
735760
end
736761
end
737762

738763
def test_should_allow_all_methods_without_token
739-
SecureRandom.stub :base64, @token do
764+
SecureRandom.stub :urlsafe_base64, @token do
740765
[:post, :patch, :put, :delete].each do |method|
741766
assert_nothing_raised { send(method, :index) }
742767
end
743768
end
744769
end
745770

746771
test "should not emit a csrf-token meta tag" do
747-
SecureRandom.stub :base64, @token do
772+
SecureRandom.stub :urlsafe_base64, @token do
748773
get :meta
749774
assert_predicate @response.body, :blank?
750775
end
@@ -756,7 +781,7 @@ def setup
756781
super
757782
@old_logger = ActionController::Base.logger
758783
@logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new
759-
@token = Base64.strict_encode64(SecureRandom.random_bytes(32))
784+
@token = Base64.urlsafe_encode64(SecureRandom.random_bytes(32))
760785
@old_request_forgery_protection_token = ActionController::Base.request_forgery_protection_token
761786
ActionController::Base.request_forgery_protection_token = @token
762787
end
@@ -1016,7 +1041,7 @@ def assert_presence_and_fetch_form_csrf_token
10161041
end
10171042

10181043
def assert_matches_session_token_on_server(form_token, method = "post")
1019-
actual = @controller.send(:unmask_token, Base64.strict_decode64(form_token))
1044+
actual = @controller.send(:unmask_token, Base64.urlsafe_decode64(form_token))
10201045
expected = @controller.send(:per_form_csrf_token, session, "/per_form_tokens/post_one", method)
10211046
assert_equal expected, actual
10221047
end

guides/source/configuring.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,8 @@ The schema dumper adds one additional configuration option:
428428
429429
* `config.action_controller.default_protect_from_forgery` determines whether forgery protection is added on `ActionController:Base`. This is false by default, but enabled when loading defaults for Rails 5.2.
430430
431+
* `config.action_controller.urlsafe_csrf_tokens` configures whether generated CSRF tokens are URL-safe. Defaults to `false`.
432+
431433
* `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']`.
432434
433435
* `config.action_controller.permit_all_parameters` sets all the parameters for mass assignment to be permitted by default. The default value is `false`.

0 commit comments

Comments
 (0)