1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Purpose Metadata For Signed And Encrypted Cookies

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.
This commit is contained in:
Assain 2018-05-19 13:31:57 +05:30
parent ba1dab1e3b
commit 1cda4fb5df
8 changed files with 218 additions and 23 deletions

View file

@ -81,6 +81,10 @@ module ActionDispatch
get_header Cookies::COOKIES_ROTATIONS get_header Cookies::COOKIES_ROTATIONS
end end
def use_cookies_with_metadata
get_header Cookies::USE_COOKIES_WITH_METADATA
end
# :startdoc: # :startdoc:
end end
@ -182,6 +186,7 @@ module ActionDispatch
COOKIES_SERIALIZER = "action_dispatch.cookies_serializer".freeze COOKIES_SERIALIZER = "action_dispatch.cookies_serializer".freeze
COOKIES_DIGEST = "action_dispatch.cookies_digest".freeze COOKIES_DIGEST = "action_dispatch.cookies_digest".freeze
COOKIES_ROTATIONS = "action_dispatch.cookies_rotations".freeze COOKIES_ROTATIONS = "action_dispatch.cookies_rotations".freeze
USE_COOKIES_WITH_METADATA = "action_dispatch.use_cookies_with_metadata".freeze
# Cookies can typically store 4096 bytes. # Cookies can typically store 4096 bytes.
MAX_COOKIE_SIZE = 4096 MAX_COOKIE_SIZE = 4096
@ -470,7 +475,7 @@ module ActionDispatch
def [](name) def [](name)
if data = @parent_jar[name.to_s] if data = @parent_jar[name.to_s]
parse name, data parse(name, data, purpose: "cookie.#{name}") || parse(name, data)
end end
end end
@ -481,7 +486,7 @@ module ActionDispatch
options = { value: options } options = { value: options }
end end
commit(options) commit(name, options)
@parent_jar[name] = options @parent_jar[name] = options
end end
@ -497,13 +502,24 @@ module ActionDispatch
end end
end end
def parse(name, data); data; end def cookie_metadata(name, options)
def commit(options); end if request.use_cookies_with_metadata
metadata = expiry_options(options)
metadata[:purpose] = "cookie.#{name}"
metadata
else
{}
end
end
def parse(name, data, purpose: nil); data; end
def commit(name, options); end
end end
class PermanentCookieJar < AbstractCookieJar # :nodoc: class PermanentCookieJar < AbstractCookieJar # :nodoc:
private private
def commit(options) def commit(name, options)
options[:expires] = 20.years.from_now options[:expires] = 20.years.from_now
end end
end end
@ -583,14 +599,14 @@ module ActionDispatch
end end
private private
def parse(name, signed_message) def parse(name, signed_message, purpose: nil)
deserialize(name) do |rotate| deserialize(name) do |rotate|
@verifier.verified(signed_message, on_rotation: rotate) @verifier.verified(signed_message, on_rotation: rotate, purpose: purpose)
end end
end end
def commit(options) def commit(name, options)
options[:value] = @verifier.generate(serialize(options[:value]), expiry_options(options)) options[:value] = @verifier.generate(serialize(options[:value]), cookie_metadata(name, options))
raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE
end end
@ -631,16 +647,16 @@ module ActionDispatch
end end
private private
def parse(name, encrypted_message) def parse(name, encrypted_message, purpose: nil)
deserialize(name) do |rotate| deserialize(name) do |rotate|
@encryptor.decrypt_and_verify(encrypted_message, on_rotation: rotate) @encryptor.decrypt_and_verify(encrypted_message, on_rotation: rotate, purpose: purpose)
end end
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature
parse_legacy_signed_message(name, encrypted_message) parse_legacy_signed_message(name, encrypted_message)
end end
def commit(options) def commit(name, options)
options[:value] = @encryptor.encrypt_and_sign(serialize(options[:value]), expiry_options(options)) options[:value] = @encryptor.encrypt_and_sign(serialize(options[:value]), cookie_metadata(name, options))
raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE
end end

View file

@ -21,6 +21,7 @@ module ActionDispatch
config.action_dispatch.encrypted_signed_cookie_salt = "signed encrypted cookie" config.action_dispatch.encrypted_signed_cookie_salt = "signed encrypted cookie"
config.action_dispatch.authenticated_encrypted_cookie_salt = "authenticated encrypted cookie" config.action_dispatch.authenticated_encrypted_cookie_salt = "authenticated encrypted cookie"
config.action_dispatch.use_authenticated_cookie_encryption = false config.action_dispatch.use_authenticated_cookie_encryption = false
config.action_dispatch.use_cookies_with_metadata = false
config.action_dispatch.perform_deep_munge = true config.action_dispatch.perform_deep_munge = true
config.action_dispatch.default_headers = { config.action_dispatch.default_headers = {

View file

@ -289,6 +289,46 @@ class CookiesTest < ActionController::TestCase
cookies[:user_name] = { value: "assain", expires: 2.hours } cookies[:user_name] = { value: "assain", expires: 2.hours }
head :ok head :ok
end end
def encrypted_discount_and_user_id_cookie
cookies.encrypted[:user_id] = { value: 50, expires: 1.hour }
cookies.encrypted[:discount_percentage] = 10
head :ok
end
def signed_discount_and_user_id_cookie
cookies.signed[:user_id] = { value: 50, expires: 1.hour }
cookies.signed[:discount_percentage] = 10
head :ok
end
def rails_5_2_stable_encrypted_cookie_with_authenticated_encryption_flag_on
# cookies.encrypted[:favorite] = { value: "5-2-Stable Chocolate Cookies", expires: 1000.years }
cookies[:favorite] = "KvH5lIHvX5vPQkLIK63r/NuIMwzWky8M0Zwk8SZ6DwUv8+srf36geR4nWq5KmhsZIYXA8NRdCZYIfxMKJsOFlz77Gf+Fq8vBBCWJTp95rx39A28TCUTJEyMhCNJO5eie7Skef76Qt5Jo/SCnIADAhzyGQkGBopKRcA==--qXZZFWGbCy6N8AGy--WswoH+xHrNh9MzSXDpB2fA=="
head :ok
end
def rails_5_2_stable_encrypted_cookie_with_authenticated_encryption_flag_off
cookies[:favorite] = "Wmg4amgvcVVvWGcwK3c4WjJEbTdRQUgrWXhBdDliUTR0cVNidXpmVTMrc2RjcitwUzVsWWEwZGtuVGtFUjJwNi0tcVhVMTFMOTQ1d0hIVE1FK0pJc05SQT09--8b2a55c375049a50f7a959b9d42b31ef0b2bb594"
head :ok
end
def rails_5_2_stable_signed_cookie_with_authenticated_encryption_flag_on
# cookies.signed[:favorite] = { value: "5-2-Stable Choco Chip Cookie", expires: 1000.years }
cookies[:favorite] = "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaEpJaUUxTFRJdFUzUmhZbXhsSUVOb2IyTnZJRU5vYVhBZ1EyOXZhMmxsQmpvR1JWUT0iLCJleHAiOiIzMDE4LTA3LTExVDE2OjExOjI2Ljc1M1oiLCJwdXIiOm51bGx9fQ==--7df5d885b78b70a501d6e82140ae91b24060ac00"
head :ok
end
def rails_5_2_stable_signed_cookie_with_authenticated_encryption_flag_off
cookies[:favorite] = "BAhJIiE1LTItU3RhYmxlIENob2NvIENoaXAgQ29va2llBjoGRVQ=--50bbdbf8d64f5a3ec3e54878f54d4f55b6cb3aff"
head :ok
end
end end
tests TestController tests TestController
@ -1274,6 +1314,8 @@ class CookiesTest < ActionController::TestCase
end end
def test_signed_cookie_with_expires_set_relatively def test_signed_cookie_with_expires_set_relatively
request.env["action_dispatch.use_cookies_with_metadata"] = true
cookies.signed[:user_name] = { value: "assain", expires: 2.hours } cookies.signed[:user_name] = { value: "assain", expires: 2.hours }
travel 1.hour travel 1.hour
@ -1284,6 +1326,8 @@ class CookiesTest < ActionController::TestCase
end end
def test_encrypted_cookie_with_expires_set_relatively def test_encrypted_cookie_with_expires_set_relatively
request.env["action_dispatch.use_cookies_with_metadata"] = true
cookies.encrypted[:user_name] = { value: "assain", expires: 2.hours } cookies.encrypted[:user_name] = { value: "assain", expires: 2.hours }
travel 1.hour travel 1.hour
@ -1300,6 +1344,128 @@ class CookiesTest < ActionController::TestCase
end end
end end
def test_purpose_metadata_for_encrypted_cookies
get :encrypted_discount_and_user_id_cookie
cookies[:discount_percentage] = cookies[:user_id]
assert_equal 50, cookies.encrypted[:discount_percentage]
request.env["action_dispatch.use_cookies_with_metadata"] = true
get :encrypted_discount_and_user_id_cookie
cookies[:discount_percentage] = cookies[:user_id]
assert_nil cookies.encrypted[:discount_percentage]
end
def test_purpose_metadata_for_signed_cookies
get :signed_discount_and_user_id_cookie
cookies[:discount_percentage] = cookies[:user_id]
assert_equal 50, cookies.signed[:discount_percentage]
request.env["action_dispatch.use_cookies_with_metadata"] = true
get :signed_discount_and_user_id_cookie
cookies[:discount_percentage] = cookies[:user_id]
assert_nil cookies.signed[:discount_percentage]
end
def test_switch_off_metadata_for_encrypted_cookies_if_config_is_false
request.env["action_dispatch.use_cookies_with_metadata"] = false
get :encrypted_discount_and_user_id_cookie
travel 2.hours
assert_equal 50, cookies.encrypted[:user_id]
cookies[:discount_percentage] = cookies[:user_id]
assert_not_equal 10, cookies.encrypted[:discount_percentage]
assert_equal 50, cookies.encrypted[:discount_percentage]
end
def test_switch_off_metadata_for_signed_cookies_if_config_is_false
request.env["action_dispatch.use_cookies_with_metadata"] = false
get :signed_discount_and_user_id_cookie
travel 2.hours
assert_equal 50, cookies.signed[:user_id]
cookies[:discount_percentage] = cookies[:user_id]
assert_not_equal 10, cookies.signed[:discount_percentage]
assert_equal 50, cookies.signed[:discount_percentage]
end
def test_read_rails_5_2_stable_encrypted_cookies_if_config_is_false
request.env["action_dispatch.use_cookies_with_metadata"] = false
get :rails_5_2_stable_encrypted_cookie_with_authenticated_encryption_flag_on
assert_equal "5-2-Stable Chocolate Cookies", cookies.encrypted[:favorite]
freeze_time do
travel 1001.years
assert_nil cookies.encrypted[:favorite]
end
get :rails_5_2_stable_encrypted_cookie_with_authenticated_encryption_flag_off
assert_equal "5-2-Stable Chocolate Cookies", cookies.encrypted[:favorite]
end
def test_read_rails_5_2_stable_signed_cookies_if_config_is_false
request.env["action_dispatch.use_cookies_with_metadata"] = false
get :rails_5_2_stable_signed_cookie_with_authenticated_encryption_flag_on
assert_equal "5-2-Stable Choco Chip Cookie", cookies.signed[:favorite]
freeze_time do
travel 1001.years
assert_nil cookies.signed[:favorite]
end
get :rails_5_2_stable_signed_cookie_with_authenticated_encryption_flag_off
assert_equal "5-2-Stable Choco Chip Cookie", cookies.signed[:favorite]
end
def test_read_rails_5_2_stable_encrypted_cookies_if_use_metadata_config_is_true
request.env["action_dispatch.use_cookies_with_metadata"] = true
get :rails_5_2_stable_encrypted_cookie_with_authenticated_encryption_flag_on
assert_equal "5-2-Stable Chocolate Cookies", cookies.encrypted[:favorite]
freeze_time do
travel 1001.years
assert_nil cookies.encrypted[:favorite]
end
get :rails_5_2_stable_encrypted_cookie_with_authenticated_encryption_flag_off
assert_equal "5-2-Stable Chocolate Cookies", cookies.encrypted[:favorite]
end
def test_read_rails_5_2_stable_signed_cookies_if_use_metadata_config_is_true
request.env["action_dispatch.use_cookies_with_metadata"] = true
get :rails_5_2_stable_signed_cookie_with_authenticated_encryption_flag_on
assert_equal "5-2-Stable Choco Chip Cookie", cookies.signed[:favorite]
freeze_time do
travel 1001.years
assert_nil cookies.signed[:favorite]
end
get :rails_5_2_stable_signed_cookie_with_authenticated_encryption_flag_off
assert_equal "5-2-Stable Choco Chip Cookie", cookies.signed[:favorite]
end
private private
def assert_cookie_header(expected) def assert_cookie_header(expected)
header = @response.headers["Set-Cookie"] header = @response.headers["Set-Cookie"]

View file

@ -267,6 +267,7 @@ module Rails
"action_dispatch.cookies_serializer" => config.action_dispatch.cookies_serializer, "action_dispatch.cookies_serializer" => config.action_dispatch.cookies_serializer,
"action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest, "action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest,
"action_dispatch.cookies_rotations" => config.action_dispatch.cookies_rotations, "action_dispatch.cookies_rotations" => config.action_dispatch.cookies_rotations,
"action_dispatch.use_cookies_with_metadata" => config.action_dispatch.use_cookies_with_metadata,
"action_dispatch.content_security_policy" => config.content_security_policy, "action_dispatch.content_security_policy" => config.content_security_policy,
"action_dispatch.content_security_policy_report_only" => config.content_security_policy_report_only, "action_dispatch.content_security_policy_report_only" => config.content_security_policy_report_only,
"action_dispatch.content_security_policy_nonce_generator" => config.content_security_policy_nonce_generator "action_dispatch.content_security_policy_nonce_generator" => config.content_security_policy_nonce_generator

View file

@ -120,6 +120,10 @@ module Rails
if respond_to?(:action_view) if respond_to?(:action_view)
action_view.default_enforce_utf8 = false action_view.default_enforce_utf8 = false
end end
if respond_to?(:action_dispatch)
action_dispatch.use_cookies_with_metadata = true
end
else else
raise "Unknown version #{target_version.to_s.inspect}" raise "Unknown version #{target_version.to_s.inspect}"
end end

View file

@ -8,3 +8,10 @@
# Don't force requests from old versions of IE to be UTF-8 encoded # Don't force requests from old versions of IE to be UTF-8 encoded
# Rails.application.config.action_view.default_enforce_utf8 = false # Rails.application.config.action_view.default_enforce_utf8 = false
# Embed purpose and expiry metadata inside signed and encrypted
# cookies for increased security.
#
# This option is not backwards compatible with earlier Rails versions.
# It's best enabled when your entire app is migrated and stable on 6.0.
# Rails.application.config.action_dispatch.use_cookies_with_metadata = true

View file

@ -110,14 +110,14 @@ module ApplicationTests
assert_equal "signed cookie".inspect, last_response.body assert_equal "signed cookie".inspect, last_response.body
get "/foo/read_raw_cookie" get "/foo/read_raw_cookie"
assert_equal "signed cookie", verifier_sha512.verify(last_response.body) assert_equal "signed cookie", verifier_sha512.verify(last_response.body, purpose: "cookie.signed_cookie")
get "/foo/write_raw_cookie_sha256" get "/foo/write_raw_cookie_sha256"
get "/foo/read_signed" get "/foo/read_signed"
assert_equal "signed cookie".inspect, last_response.body assert_equal "signed cookie".inspect, last_response.body
get "/foo/read_raw_cookie" get "/foo/read_raw_cookie"
assert_equal "signed cookie", verifier_sha512.verify(last_response.body) assert_equal "signed cookie", verifier_sha512.verify(last_response.body, purpose: "cookie.signed_cookie")
end end
test "encrypted cookies rotating multiple encryption keys" do test "encrypted cookies rotating multiple encryption keys" do
@ -180,14 +180,14 @@ module ApplicationTests
assert_equal "encrypted cookie".inspect, last_response.body assert_equal "encrypted cookie".inspect, last_response.body
get "/foo/read_raw_cookie" get "/foo/read_raw_cookie"
assert_equal "encrypted cookie", encryptor.decrypt_and_verify(last_response.body) assert_equal "encrypted cookie", encryptor.decrypt_and_verify(last_response.body, purpose: "cookie.encrypted_cookie")
get "/foo/write_raw_cookie_sha256" get "/foo/write_raw_cookie_two"
get "/foo/read_encrypted" get "/foo/read_encrypted"
assert_equal "encrypted cookie".inspect, last_response.body assert_equal "encrypted cookie".inspect, last_response.body
get "/foo/read_raw_cookie" get "/foo/read_raw_cookie"
assert_equal "encrypted cookie", encryptor.decrypt_and_verify(last_response.body) assert_equal "encrypted cookie", encryptor.decrypt_and_verify(last_response.body, purpose: "cookie.encrypted_cookie")
end end
end end
end end

View file

@ -183,7 +183,7 @@ module ApplicationTests
encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher) encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher)
get "/foo/read_raw_cookie" get "/foo/read_raw_cookie"
assert_equal 1, encryptor.decrypt_and_verify(last_response.body)["foo"] assert_equal 1, encryptor.decrypt_and_verify(last_response.body, purpose: "cookie._myapp_session")["foo"]
end end
test "session upgrading signature to encryption cookie store works the same way as encrypted cookie store" do test "session upgrading signature to encryption cookie store works the same way as encrypted cookie store" do
@ -235,7 +235,7 @@ module ApplicationTests
encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher) encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher)
get "/foo/read_raw_cookie" get "/foo/read_raw_cookie"
assert_equal 1, encryptor.decrypt_and_verify(last_response.body)["foo"] assert_equal 1, encryptor.decrypt_and_verify(last_response.body, purpose: "cookie._myapp_session")["foo"]
end end
test "session upgrading signature to encryption cookie store upgrades session to encrypted mode" do test "session upgrading signature to encryption cookie store upgrades session to encrypted mode" do
@ -297,7 +297,7 @@ module ApplicationTests
encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher) encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher)
get "/foo/read_raw_cookie" get "/foo/read_raw_cookie"
assert_equal 2, encryptor.decrypt_and_verify(last_response.body)["foo"] assert_equal 2, encryptor.decrypt_and_verify(last_response.body, purpose: "cookie._myapp_session")["foo"]
end end
test "session upgrading from AES-CBC-HMAC encryption to AES-GCM encryption" do test "session upgrading from AES-CBC-HMAC encryption to AES-GCM encryption" do
@ -364,7 +364,7 @@ module ApplicationTests
encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher) encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher)
get "/foo/read_raw_cookie" get "/foo/read_raw_cookie"
assert_equal 2, encryptor.decrypt_and_verify(last_response.body)["foo"] assert_equal 2, encryptor.decrypt_and_verify(last_response.body, purpose: "cookie._myapp_session")["foo"]
ensure ensure
ENV["RAILS_ENV"] = old_rails_env ENV["RAILS_ENV"] = old_rails_env
end end
@ -428,7 +428,7 @@ module ApplicationTests
verifier = ActiveSupport::MessageVerifier.new(app.secrets.secret_token) verifier = ActiveSupport::MessageVerifier.new(app.secrets.secret_token)
get "/foo/read_raw_cookie" get "/foo/read_raw_cookie"
assert_equal 2, verifier.verify(last_response.body)["foo"] assert_equal 2, verifier.verify(last_response.body, purpose: "cookie._myapp_session")["foo"]
ensure ensure
ENV["RAILS_ENV"] = old_rails_env ENV["RAILS_ENV"] = old_rails_env
end end