Skip to content

Commit 7ccaa12

Browse files
cfabianskikaspth
authored andcommitted
Add SameSite protection to every written cookie
Enabling `SameSite` cookie protection is an addition to CSRF protection, where cookies won't be sent by browsers in cross-site POST requests when set to `:lax`. `:strict` disables cookies being sent in cross-site GET or POST requests. Passing `:none` disables this protection and is the same as previous versions albeit a `; SameSite=None` is appended to the cookie. See upgrade instructions in config/initializers/new_framework_defaults_6_1.rb. More info [here](https://tools.ietf.org/html/draft-west-first-party-cookies-07) _NB: Technically already possible as Rack supports SameSite protection, this is to ensure it's applied to all cookies_
1 parent 4500ebb commit 7ccaa12

File tree

8 files changed

+114
-35
lines changed

8 files changed

+114
-35
lines changed

actionpack/CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
* Add SameSite protection to every written cookie.
2+
3+
Enabling `SameSite` cookie protection is an addition to CSRF protection,
4+
where cookies won't be sent by browsers in cross-site POST requests when set to `:lax`.
5+
6+
`:strict` disables cookies being sent in cross-site GET or POST requests.
7+
8+
Passing `:none` disables this protection and is the same as previous versions albeit a `; SameSite=None` is appended to the cookie.
9+
10+
See upgrade instructions in config/initializers/new_framework_defaults_6_1.rb.
11+
12+
More info [here](https://tools.ietf.org/html/draft-west-first-party-cookies-07)
13+
14+
_NB: Technically already possible as Rack supports SameSite protection, this is to ensure it's applied to all cookies_
15+
16+
*Cédric Fabianski*
17+
118
* Bring back the feature that allows loading external route files from the router.
219

320
This feature existed back in 2012 but got reverted with the incentive that

actionpack/lib/action_dispatch/middleware/cookies.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ def cookies_serializer
6969
get_header Cookies::COOKIES_SERIALIZER
7070
end
7171

72+
def cookies_same_site_protection
73+
get_header Cookies::COOKIES_SAME_SITE_PROTECTION
74+
end
75+
7276
def cookies_digest
7377
get_header Cookies::COOKIES_DIGEST
7478
end
@@ -181,6 +185,7 @@ class Cookies
181185
COOKIES_SERIALIZER = "action_dispatch.cookies_serializer"
182186
COOKIES_DIGEST = "action_dispatch.cookies_digest"
183187
COOKIES_ROTATIONS = "action_dispatch.cookies_rotations"
188+
COOKIES_SAME_SITE_PROTECTION = "action_dispatch.cookies_same_site_protection"
184189
USE_COOKIES_WITH_METADATA = "action_dispatch.use_cookies_with_metadata"
185190

186191
# Cookies can typically store 4096 bytes.
@@ -431,7 +436,9 @@ def handle_options(options)
431436
options[:expires] = options[:expires].from_now
432437
end
433438

434-
options[:path] ||= "/"
439+
options[:path] ||= "/"
440+
options[:same_site] ||= request.cookies_same_site_protection
441+
options[:same_site] = false if options[:same_site] == :none # TODO: Remove when rack 2.1.0 is out.
435442

436443
if options[:domain] == :all || options[:domain] == "all"
437444
# If there is a provided tld length then we use it otherwise default domain regexp.

actionpack/test/dispatch/cookies_test.rb

Lines changed: 58 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -358,12 +358,38 @@ def setup
358358
@request.env["action_dispatch.encrypted_signed_cookie_salt"] = ENCRYPTED_SIGNED_COOKIE_SALT
359359
@request.env["action_dispatch.authenticated_encrypted_cookie_salt"] = AUTHENTICATED_ENCRYPTED_COOKIE_SALT
360360

361+
@request.env["action_dispatch.cookies_same_site_protection"] = :lax
361362
@request.host = "www.nextangle.com"
362363
end
363364

365+
def test_setting_cookie_with_no_protection
366+
@request.env["action_dispatch.cookies_same_site_protection"] = :none
367+
368+
get :authenticate
369+
assert_cookie_header "user_name=david; path=/" # TODO: append "; SameSite=None" when rack 2.1.0 is out and bump rack dependency version.
370+
assert_equal({ "user_name" => "david" }, @response.cookies)
371+
end
372+
373+
def test_setting_cookie_with_misspelled_protection_raises
374+
@request.env["action_dispatch.cookies_same_site_protection"] = :funky
375+
376+
error = assert_raise ArgumentError do
377+
get :authenticate
378+
end
379+
assert_match "Invalid SameSite value: :funky", error.message
380+
end
381+
382+
def test_setting_cookie_with_strict
383+
@request.env["action_dispatch.cookies_same_site_protection"] = :strict
384+
385+
get :authenticate
386+
assert_cookie_header "user_name=david; path=/; SameSite=Strict"
387+
assert_equal({ "user_name" => "david" }, @response.cookies)
388+
end
389+
364390
def test_setting_cookie
365391
get :authenticate
366-
assert_cookie_header "user_name=david; path=/"
392+
assert_cookie_header "user_name=david; path=/; SameSite=Lax"
367393
assert_equal({ "user_name" => "david" }, @response.cookies)
368394
end
369395

@@ -381,39 +407,39 @@ def test_setting_the_same_value_to_permanent_cookie
381407

382408
def test_setting_with_escapable_characters
383409
get :set_with_with_escapable_characters
384-
assert_cookie_header "that+%26+guy=foo+%26+bar+%3D%3E+baz; path=/"
410+
assert_cookie_header "that+%26+guy=foo+%26+bar+%3D%3E+baz; path=/; SameSite=Lax"
385411
assert_equal({ "that & guy" => "foo & bar => baz" }, @response.cookies)
386412
end
387413

388414
def test_setting_cookie_for_fourteen_days
389415
get :authenticate_for_fourteen_days
390-
assert_cookie_header "user_name=david; path=/; expires=Mon, 10 Oct 2005 05:00:00 -0000"
416+
assert_cookie_header "user_name=david; path=/; expires=Mon, 10 Oct 2005 05:00:00 -0000; SameSite=Lax"
391417
assert_equal({ "user_name" => "david" }, @response.cookies)
392418
end
393419

394420
def test_setting_cookie_for_fourteen_days_with_symbols
395421
get :authenticate_for_fourteen_days_with_symbols
396-
assert_cookie_header "user_name=david; path=/; expires=Mon, 10 Oct 2005 05:00:00 -0000"
422+
assert_cookie_header "user_name=david; path=/; expires=Mon, 10 Oct 2005 05:00:00 -0000; SameSite=Lax"
397423
assert_equal({ "user_name" => "david" }, @response.cookies)
398424
end
399425

400426
def test_setting_cookie_with_http_only
401427
get :authenticate_with_http_only
402-
assert_cookie_header "user_name=david; path=/; HttpOnly"
428+
assert_cookie_header "user_name=david; path=/; HttpOnly; SameSite=Lax"
403429
assert_equal({ "user_name" => "david" }, @response.cookies)
404430
end
405431

406432
def test_setting_cookie_with_secure
407433
@request.env["HTTPS"] = "on"
408434
get :authenticate_with_secure
409-
assert_cookie_header "user_name=david; path=/; secure"
435+
assert_cookie_header "user_name=david; path=/; secure; SameSite=Lax"
410436
assert_equal({ "user_name" => "david" }, @response.cookies)
411437
end
412438

413439
def test_setting_cookie_with_secure_when_always_write_cookie_is_true
414440
old_cookie, @request.cookie_jar.always_write_cookie = @request.cookie_jar.always_write_cookie, true
415441
get :authenticate_with_secure
416-
assert_cookie_header "user_name=david; path=/; secure"
442+
assert_cookie_header "user_name=david; path=/; secure; SameSite=Lax"
417443
assert_equal({ "user_name" => "david" }, @response.cookies)
418444
ensure
419445
@request.cookie_jar.always_write_cookie = old_cookie
@@ -428,7 +454,7 @@ def test_not_setting_cookie_with_secure
428454
def test_multiple_cookies
429455
get :set_multiple_cookies
430456
assert_equal 2, @response.cookies.size
431-
assert_cookie_header "user_name=david; path=/; expires=Mon, 10 Oct 2005 05:00:00 -0000\nlogin=XJ-122; path=/"
457+
assert_cookie_header "user_name=david; path=/; expires=Mon, 10 Oct 2005 05:00:00 -0000; SameSite=Lax\nlogin=XJ-122; path=/; SameSite=Lax"
432458
assert_equal({ "login" => "XJ-122", "user_name" => "david" }, @response.cookies)
433459
end
434460

@@ -439,14 +465,14 @@ def test_setting_test_cookie
439465
def test_expiring_cookie
440466
request.cookies[:user_name] = "Joe"
441467
get :logout
442-
assert_cookie_header "user_name=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000"
468+
assert_cookie_header "user_name=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000; SameSite=Lax"
443469
assert_equal({ "user_name" => nil }, @response.cookies)
444470
end
445471

446472
def test_delete_cookie_with_path
447473
request.cookies[:user_name] = "Joe"
448474
get :delete_cookie_with_path
449-
assert_cookie_header "user_name=; path=/beaten; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000"
475+
assert_cookie_header "user_name=; path=/beaten; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000; SameSite=Lax"
450476
end
451477

452478
def test_delete_unexisting_cookie
@@ -723,7 +749,7 @@ def test_permanent_signed_cookie
723749
def test_delete_and_set_cookie
724750
request.cookies[:user_name] = "Joe"
725751
get :delete_and_set_cookie
726-
assert_cookie_header "user_name=david; path=/; expires=Mon, 10 Oct 2005 05:00:00 -0000"
752+
assert_cookie_header "user_name=david; path=/; expires=Mon, 10 Oct 2005 05:00:00 -0000; SameSite=Lax"
727753
assert_equal({ "user_name" => "david" }, @response.cookies)
728754
end
729755

@@ -909,134 +935,134 @@ def test_cookie_with_hash_value_not_modified_by_rotation
909935
def test_cookie_with_all_domain_option
910936
get :set_cookie_with_domain
911937
assert_response :success
912-
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.com; path=/"
938+
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.com; path=/; SameSite=Lax"
913939
end
914940

915941
def test_cookie_with_all_domain_option_using_a_non_standard_tld
916942
@request.host = "two.subdomains.nextangle.local"
917943
get :set_cookie_with_domain
918944
assert_response :success
919-
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.local; path=/"
945+
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.local; path=/; SameSite=Lax"
920946
end
921947

922948
def test_cookie_with_all_domain_option_using_australian_style_tld
923949
@request.host = "nextangle.com.au"
924950
get :set_cookie_with_domain
925951
assert_response :success
926-
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.com.au; path=/"
952+
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.com.au; path=/; SameSite=Lax"
927953
end
928954

929955
def test_cookie_with_all_domain_option_using_uk_style_tld
930956
@request.host = "nextangle.co.uk"
931957
get :set_cookie_with_domain
932958
assert_response :success
933-
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.co.uk; path=/"
959+
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.co.uk; path=/; SameSite=Lax"
934960
end
935961

936962
def test_cookie_with_all_domain_option_using_host_with_port
937963
@request.host = "nextangle.local:3000"
938964
get :set_cookie_with_domain
939965
assert_response :success
940-
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.local; path=/"
966+
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.local; path=/; SameSite=Lax"
941967
end
942968

943969
def test_cookie_with_all_domain_option_using_localhost
944970
@request.host = "localhost"
945971
get :set_cookie_with_domain
946972
assert_response :success
947-
assert_cookie_header "user_name=rizwanreza; path=/"
973+
assert_cookie_header "user_name=rizwanreza; path=/; SameSite=Lax"
948974
end
949975

950976
def test_cookie_with_all_domain_option_using_ipv4_address
951977
@request.host = "192.168.1.1"
952978
get :set_cookie_with_domain
953979
assert_response :success
954-
assert_cookie_header "user_name=rizwanreza; path=/"
980+
assert_cookie_header "user_name=rizwanreza; path=/; SameSite=Lax"
955981
end
956982

957983
def test_cookie_with_all_domain_option_using_ipv6_address
958984
@request.host = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
959985
get :set_cookie_with_domain
960986
assert_response :success
961-
assert_cookie_header "user_name=rizwanreza; path=/"
987+
assert_cookie_header "user_name=rizwanreza; path=/; SameSite=Lax"
962988
end
963989

964990
def test_deleting_cookie_with_all_domain_option
965991
request.cookies[:user_name] = "Joe"
966992
get :delete_cookie_with_domain
967993
assert_response :success
968-
assert_cookie_header "user_name=; domain=.nextangle.com; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000"
994+
assert_cookie_header "user_name=; domain=.nextangle.com; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000; SameSite=Lax"
969995
end
970996

971997
def test_cookie_with_all_domain_option_and_tld_length
972998
get :set_cookie_with_domain_and_tld
973999
assert_response :success
974-
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.com; path=/"
1000+
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.com; path=/; SameSite=Lax"
9751001
end
9761002

9771003
def test_cookie_with_all_domain_option_using_a_non_standard_tld_and_tld_length
9781004
@request.host = "two.subdomains.nextangle.local"
9791005
get :set_cookie_with_domain_and_tld
9801006
assert_response :success
981-
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.local; path=/"
1007+
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.local; path=/; SameSite=Lax"
9821008
end
9831009

9841010
def test_cookie_with_all_domain_option_using_a_non_standard_2_letter_tld
9851011
@request.host = "admin.lvh.me"
9861012
get :set_cookie_with_domain_and_tld
9871013
assert_response :success
988-
assert_cookie_header "user_name=rizwanreza; domain=.lvh.me; path=/"
1014+
assert_cookie_header "user_name=rizwanreza; domain=.lvh.me; path=/; SameSite=Lax"
9891015
end
9901016

9911017
def test_cookie_with_all_domain_option_using_host_with_port_and_tld_length
9921018
@request.host = "nextangle.local:3000"
9931019
get :set_cookie_with_domain_and_tld
9941020
assert_response :success
995-
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.local; path=/"
1021+
assert_cookie_header "user_name=rizwanreza; domain=.nextangle.local; path=/; SameSite=Lax"
9961022
end
9971023

9981024
def test_deleting_cookie_with_all_domain_option_and_tld_length
9991025
request.cookies[:user_name] = "Joe"
10001026
get :delete_cookie_with_domain_and_tld
10011027
assert_response :success
1002-
assert_cookie_header "user_name=; domain=.nextangle.com; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000"
1028+
assert_cookie_header "user_name=; domain=.nextangle.com; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000; SameSite=Lax"
10031029
end
10041030

10051031
def test_cookie_with_several_preset_domains_using_one_of_these_domains
10061032
@request.host = "example1.com"
10071033
get :set_cookie_with_domains
10081034
assert_response :success
1009-
assert_cookie_header "user_name=rizwanreza; domain=example1.com; path=/"
1035+
assert_cookie_header "user_name=rizwanreza; domain=example1.com; path=/; SameSite=Lax"
10101036
end
10111037

10121038
def test_cookie_with_several_preset_domains_using_other_domain
10131039
@request.host = "other-domain.com"
10141040
get :set_cookie_with_domains
10151041
assert_response :success
1016-
assert_cookie_header "user_name=rizwanreza; path=/"
1042+
assert_cookie_header "user_name=rizwanreza; path=/; SameSite=Lax"
10171043
end
10181044

10191045
def test_cookie_with_several_preset_domains_using_shared_domain
10201046
@request.host = "example3.com"
10211047
get :set_cookie_with_domains
10221048
assert_response :success
1023-
assert_cookie_header "user_name=rizwanreza; domain=.example3.com; path=/"
1049+
assert_cookie_header "user_name=rizwanreza; domain=.example3.com; path=/; SameSite=Lax"
10241050
end
10251051

10261052
def test_deletings_cookie_with_several_preset_domains_using_one_of_these_domains
10271053
@request.host = "example2.com"
10281054
request.cookies[:user_name] = "Joe"
10291055
get :delete_cookie_with_domains
10301056
assert_response :success
1031-
assert_cookie_header "user_name=; domain=example2.com; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000"
1057+
assert_cookie_header "user_name=; domain=example2.com; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000; SameSite=Lax"
10321058
end
10331059

10341060
def test_deletings_cookie_with_several_preset_domains_using_other_domain
10351061
@request.host = "other-domain.com"
10361062
request.cookies[:user_name] = "Joe"
10371063
get :delete_cookie_with_domains
10381064
assert_response :success
1039-
assert_cookie_header "user_name=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000"
1065+
assert_cookie_header "user_name=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000; SameSite=Lax"
10401066
end
10411067

10421068
def test_cookies_hash_is_indifferent_access
@@ -1062,7 +1088,7 @@ def test_setting_request_cookies_is_indifferent_access
10621088

10631089
def test_cookies_retained_across_requests
10641090
get :symbol_key
1065-
assert_cookie_header "user_name=david; path=/"
1091+
assert_cookie_header "user_name=david; path=/; SameSite=Lax"
10661092
assert_equal "david", cookies[:user_name]
10671093

10681094
get :noop
@@ -1181,7 +1207,7 @@ def test_encrypted_cookie_with_expires_set_relatively
11811207
def test_vanilla_cookie_with_expires_set_relatively
11821208
travel_to Time.utc(2017, 8, 15) do
11831209
get :cookie_expires_in_two_hours
1184-
assert_cookie_header "user_name=assain; path=/; expires=Tue, 15 Aug 2017 02:00:00 -0000"
1210+
assert_cookie_header "user_name=assain; path=/; expires=Tue, 15 Aug 2017 02:00:00 -0000; SameSite=Lax"
11851211
end
11861212
end
11871213

railties/lib/rails/application.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ def env_config
267267
"action_dispatch.cookies_serializer" => config.action_dispatch.cookies_serializer,
268268
"action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest,
269269
"action_dispatch.cookies_rotations" => config.action_dispatch.cookies_rotations,
270+
"action_dispatch.cookies_same_site_protection" => config.action_dispatch.cookies_same_site_protection,
270271
"action_dispatch.use_cookies_with_metadata" => config.action_dispatch.use_cookies_with_metadata,
271272
"action_dispatch.content_security_policy" => config.content_security_policy,
272273
"action_dispatch.content_security_policy_report_only" => config.content_security_policy_report_only,

railties/lib/rails/application/configuration.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,10 @@ def load_defaults(target_version)
171171
if respond_to?(:active_job)
172172
active_job.skip_after_callbacks_if_terminated = true
173173
end
174+
175+
if respond_to?(:action_dispatch)
176+
action_dispatch.cookies_same_site_protection = :lax
177+
end
174178
else
175179
raise "Unknown version #{target_version.to_s.inspect}"
176180
end

railties/lib/rails/generators/rails/app/app_generator.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ def config_when_updating
152152
end
153153

154154
if options[:api]
155-
unless cookie_serializer_config_exist
156-
remove_file "config/initializers/cookies_serializer.rb"
155+
unless cookies_config_exist
156+
remove_file "config/initializers/cookies.rb"
157157
end
158158

159159
unless csp_config_exist

railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_6_1.rb.tt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@
1313
# Rails.application.config.active_storage.track_variants = true
1414

1515
# Rails.application.config.active_job.skip_after_callbacks_if_terminated = true
16+
17+
# Specify cookies SameSite protection level: either :none, :lax, or :strict.
18+
#
19+
# This change is not backwards compatible with earlier Rails versions.
20+
# It's best enabled when your entire app is migrated and stable on 6.1.
21+
# Rails.application.config.action_dispatch.cookies_same_site_protection = :lax

0 commit comments

Comments
 (0)