-
Notifications
You must be signed in to change notification settings - Fork 21.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Purpose Metadata to Cookies #32937
Conversation
(@rails-bot has picked a reviewer for you, use r? to override) |
@@ -1300,6 +1300,22 @@ def test_vanilla_cookie_with_expires_set_relatively | |||
end | |||
end | |||
|
|||
def test_purpose_metadata_for_signed_cookies |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably want the two new tests to set the ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata
config to true
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tests related to purpose and expiry metadata are failing because I can't switch on - ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata properly, which is set to false by default.
I tried setting ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata
config to true within the new tests, but don't know why it breaks a lot of other tests. Would it be because of the way the tests are run ?
I tried switching it to true in the metadata tests like this -
def test_purpose_metadata_for_encrypted_cookies
ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata = true
cookies.encrypted[:user_id] = 50
cookies.encrypted[:discount_percentage] = 10
cookies[:discount_percentage] = cookies[:user_id]
assert_nil cookies.encrypted[:discount_percentage]
# ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata = false
end
I also tried creating a subclass of CookiesTest with setup and teardown methods. But it screws up everything and other kinds of experimentation.
Could you suggest me the right way to do this? 😕
@Edouard-chin @kaspth @guilleiguaran
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's probably because ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata = false
was never run since the execution stoped when the assertion assert_nil cookies.encrypted[:discount_percentage]
failed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had tried both. Are tests run parallelly ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They are not, I tried running the tests and they work fine with something like
Diff
diff --git a/actionpack/test/dispatch/cookies_test.rb b/actionpack/test/dispatch/cookies_test.rb
index aa9e4dac42..9a822c4f44 100644
--- a/actionpack/test/dispatch/cookies_test.rb
+++ b/actionpack/test/dispatch/cookies_test.rb
@@ -1301,19 +1301,27 @@ def test_vanilla_cookie_with_expires_set_relatively
end
def test_purpose_metadata_for_signed_cookies
+ previous_config = ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata
+ ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata = true
cookies.signed[:user_id] = 50
cookies.signed[:discount_percentage] = 10
cookies[:discount_percentage] = cookies[:user_id]
assert_nil cookies.signed[:discount_percentage]
+ ensure
+ ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata = previous_config
end
def test_purpose_metadata_for_encrypted_cookies
+ previous_config = ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata
+ ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata = true
cookies.encrypted[:user_id] = 50
cookies.encrypted[:discount_percentage] = 10
cookies[:discount_percentage] = cookies[:user_id]
assert_nil cookies.encrypted[:discount_percentage]
+ ensure
+ ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata = previous_config
end
Only two tests are failing related to expiry, but it makes sense since the expiry
option is now set solely when use_cookies_with_metadata
is true
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, @Edouard-chin for helping me out with your expertise. Got to make a few more tweaks to the PR 😄
cookies.signed[:discount_percentage] = 10 | ||
|
||
cookies[:discount] = cookies[:user_id] | ||
assert_nil cookies.signed[:discount_percentage] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this be assert_nil cookies.signed[:discount]
instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Edouard-chin Yeah, my bad 😅
@@ -1318,4 +1334,4 @@ def assert_not_cookie_header(expected) | |||
assert_not_equal expected.split("\n"), header | |||
end | |||
end | |||
end | |||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
✂️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Edouard-chin I've made some more changes 😄
cookies[:discount] = cookies[:user_id] | ||
assert_nil cookies.encrypted[:discount_percentage] | ||
end | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tests related to purpose and expiry metadata are failing, because I can't switch on - ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata
which is set to false by default.
I tried turning it to true in the metadata tests like this -
def test_purpose_metadata_for_encrypted_cookies
ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata = true
cookies.encrypted[:user_id] = 50
cookies.encrypted[:discount_percentage] = 10
cookies[:discount] = cookies[:user_id]
assert_nil cookies.encrypted[:discount_percentage]
# ActionDispatch::Cookies::AbstractCookieJar.use_cookies_with_metadata = false
end
I also tried creating a subclass of CookiesTest with setup
and teardown
methods. But it screws up everything and other kinds of experimentation.
Could you suggest me the right way to do this? 😕
1153258
to
11ae805
Compare
290e675
to
d898409
Compare
6819834
to
a8c88cd
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great overall! Nice work!!
@@ -489,11 +497,30 @@ def []=(name, options) | |||
def request; @parent_jar.request; end | |||
|
|||
private | |||
def expiry_options(options) | |||
def fetch_metadata(options) | |||
metadata = Hash.new |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could make this a bit more concise with tap
and a Hash literal:
{ purpose: options[:cookie_name] }.tap do |metadata|
# set expires_in or expires_at...
end
a8c88cd
to
803ab5c
Compare
metadata[:expires_at] = options[:expires] | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mikeycgto Was this what you had suggested? 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, exactly!
# for how long those cookies should be honored as a Time or ActiveSupport::Duration object. | ||
|
||
# Also, setting this value to nil or to a date in the past rejects the existing cookies upfront. | ||
Rails.application.config.action_dispatch.fail_cookies_without_metadata_after = <%= 1.month.from_now %> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mikeycgto @kaspth @guilleiguaran
Travis CI's GEM = aj:integration fails with a syntax error because of the output of <%= 1.month.from_now %>
. Should I convert <%= 1.month.from_now %>
into a string or any other format? Or am I missing anything?
aa4cd7e
to
cf88f3b
Compare
# for how long those cookies should be honored as a Time or ActiveSupport::Duration object. | ||
|
||
# Also, setting this value to nil or to a date in the past rejects the existing cookies upfront. | ||
Rails.application.config.action_dispatch.fail_cookies_without_metadata_after = <%= ActiveSupport::JSON.encode 1.month.from_now %> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe https://github.com/rails/rails/blob/master/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_6_0.rb.tt is a better place for this?
@kaspth wdyt?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@guilleiguaran Yes, that's the place we should put this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Initial pass! Looks like this mostly gets the job done, but the flow and readability isn't quite where we need it. But that's good! Now we have a base to expand on.
Left lots of questions to hopefully guide you in your further decision making.
cookies.signed[:user_name] = { value: "assain", expires: 2.hours } | ||
|
||
travel 1.hour | ||
assert_equal "assain", cookies.signed[:user_name] | ||
|
||
travel 2.hours | ||
assert_nil cookies.signed[:user_name] | ||
|
||
ensure | ||
switch_off_cookies_with_metadata |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having these ensures spread throughout is a bad practice.
Switch to:
with_cookie_metadata_enabled do
# …
end
Remember to capture the old value in a local variable too and reassign it back.
# for how long those cookies should be honored as a Time or ActiveSupport::Duration object. | ||
|
||
# Also, setting this value to nil or to a date in the past rejects the existing cookies upfront. | ||
Rails.application.config.action_dispatch.fail_cookies_without_metadata_after = <%= ActiveSupport::JSON.encode 1.month.from_now %> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@guilleiguaran Yes, that's the place we should put this.
# for how long those cookies should be honored as a Time or ActiveSupport::Duration object. | ||
|
||
# Also, setting this value to nil or to a date in the past rejects the existing cookies upfront. | ||
Rails.application.config.action_dispatch.fail_cookies_without_metadata_after = <%= ActiveSupport::JSON.encode 1.month.from_now %> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did you make this a JSON value here? What's the benefit of this way, as opposed to deferring it? How does this choice mesh with our goal of programmer happiness and readability?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where is use_cookies_with_metadata
set to true? Seems like we'd ship something that would never actually use metadata? And is it the best approach to split this over 2 options? E.g. is the mental overhead of 2 toggles worth it for end users?
# If you don't want your existing cookies to be rejected immediately you need to specify a window | ||
# for how long those cookies should be honored as a Time or ActiveSupport::Duration object. | ||
|
||
# Also, setting this value to nil or to a date in the past rejects the existing cookies upfront. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Put yourself in the shoes of a person who's upgrading their Rails app. What would they want to know here?
def expiry_options(options) | ||
if options[:expires].respond_to?(:from_now) | ||
{ expires_in: options[:expires] } | ||
def fetch_metadata(options) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this named fetch
? How are we fetching the metadata? And is it the input that's the most important thing here? Do we regularly call methods to pass something in or to get something out?
end | ||
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature | ||
parse_legacy_signed_message(name, encrypted_message) | ||
end | ||
|
||
def commit(options) | ||
options[:value] = @encryptor.encrypt_and_sign(serialize(options[:value]), expiry_options(options)) | ||
if cookies_with_metadata? | ||
options[:value] = @encryptor.encrypt_and_sign(serialize(options[:value]), fetch_metadata(options)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this method concerned with cookies_with_metadata?
? Especially when there's a method that's called fetch_metadata
? Same for the other instances.
@@ -481,6 +487,8 @@ def []=(name, options) | |||
options = { value: options } | |||
end | |||
|
|||
options[:cookie_name] = name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Won't this break old Rails apps as we're always writing the cookie_name
to the cookie? E.g. would a cookie from 5.2 (e.g. no metadata) be readable on 6.0? And would a metadata attached cookie from 6.0 be readable on 5.2? This is one of the most important things we have to keep in mind, so do our tests test these scenarios sufficiently and do they do so in a sufficiently realistic manner?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still don't like assigning straight to the options here. They'll be committed in the cookie body after all. Have you tried passing the name in to commit or moving the metadata construction up to here?
end | ||
value | ||
else | ||
@encryptor.decrypt_and_verify(encrypted_message, on_rotation: rotate) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling @encryptor.decrypt_and_verify(encrypted_message, on_rotation: rotate)
3 times here is a smell.
if value.nil? && read_cookies_without_metadata? | ||
value = @verifier.verified(signed_message, on_rotation: rotate) | ||
end | ||
value |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also smelly.
if options[:expires].respond_to?(:from_now) | ||
{ expires_in: options[:expires] } | ||
def fetch_metadata(options) | ||
{ purpose: options[:cookie_name] }.tap do |metadata| |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Think the tap
here blurs the flow. Is building the metadata at the same level of abstraction as normalizing the expiry? If not, how can we compose this code better?
9e9aff9
to
490cfcb
Compare
@kaspth Here's the suggested changes 😄 |
config.action_dispatch.cookies_with_purpose_metadata = { | ||
switch_on: false, | ||
honor_cookies_wihout_metadata_till: nil | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a typo here, I'll fix that...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do feel weird about having a hash ({ switch_on: true }
) as a way to enable the feature. Is there a particular reason why we can't make falsy as an off state and any object as an on state?
i.e.
.cookies_with_purpose_metadata = false # off
.cookies_with_purpose_metadata = true # on, `honor_cookies_without_metadata_till` is nil
.cookies_with_purpose_metadata = { honor_cookies_without_metadata_till: 30.days.from_now }
Also, I'm curious about the wording of honor_cookies_without_metadata_till
. I think expanding till
to until
would make it reads better, and maybe we can change honor
to allow
to, just to make it more inline of other flags that available in Rails (assuming it still conveys the same message):
config.action_dispatch.cookies_with_purpose_metadata = {
allow_cookies_without_metadata_until: 30.days.from_now
}
Please let me know what you think. Thank you for your patch.
# before they're rejected so that your application behaviour is not broken immediately. | ||
# | ||
# Rails.application.config.action_dispatch.cookies_with_purpose_metadata = { | ||
# switch_on: true, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mind adding some indentation for line 21 and 22 too?
490cfcb
to
6d4fd58
Compare
ae121b9
to
5d03ffc
Compare
Purpose metadata prevents cookie values from being copy-pasted and ensures that the cookie is used only for its originally intended purpose. The Purpose and Expiry metadata are embedded inside signed/encrypted cookies and will not be readable on previous versions of Rails. We can switch off purpose and expiry metadata embedded in signed and encrypted cookies using config.action_dispatch.use_cookies_with_metadata = false if you want your cookies to be readable on older versions of Rails.
5d03ffc
to
1cda4fb
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❤️👏
Let's clean up the test things and add a changelog in a separate PR. I don't want to hold this up any longer 😄
assert_equal "5-2-Stable Chocolate Cookies", cookies.encrypted[:favorite] | ||
|
||
freeze_time do | ||
travel 1001.years |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
freeze_time
just does travel_to Time.now
, so you could just do travel 1001.years do
.
…"Configuring Rails Applications" guide [ci skip] Related to rails#32937, rails#33605.
…[ci skip] Context rails#33605 (comment) Related to rails#32937, rails#33605
- There is a regression in 6.0 introduced by rails#32937 where cookie doesn't expire anymore unless the new `use_cookies_with_metadata` configuration is set to `true`. This causes issue for app migration from 5.2 to 6.0 because the `use_cookies_with_metadata` flag can't be set to true until all servers are running on 6.0. Here is a small reproduction script that you can run in the console ```ruby ActionDispatch::Cookies request = ActionDispatch::Request.empty request.env["action_dispatch.key_generator"] = ActiveSupport::KeyGenerator.new('1234567890') request.env["action_dispatch.signed_cookie_salt"] = 'signed cookie' request.env["action_dispatch.cookies_rotations"] = ActiveSupport::Messages::RotationConfiguration.new request.env["action_dispatch.use_authenticated_cookie_encryption"] = true signed_cookie = request.cookie_jar.signed signed_cookie[:foobar] = { value: '123', expires: 1.day.ago } p signed_cookie[:foobar] ```
Summary
This PR adds
purpose
metadata to cookies so that the value of one cookie cannot be copied and used as the value of another cookie.Other Information
Turn on
config.action_dispatch.use_cookies_with_metadata
to embed expiry and purpose metadata inside signed/encrypted cookies, and to verify if signed/encrypted cookie values have been copy-pasted. Previously set cookies (i.e. without purpose or expiry metadata) will continue to be honored.