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

View file

@ -21,6 +21,7 @@ module ActionDispatch
config.action_dispatch.encrypted_signed_cookie_salt = "signed encrypted cookie"
config.action_dispatch.authenticated_encrypted_cookie_salt = "authenticated encrypted cookie"
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.default_headers = {

View file

@ -289,6 +289,46 @@ class CookiesTest < ActionController::TestCase
cookies[:user_name] = { value: "assain", expires: 2.hours }
head :ok
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
tests TestController
@ -1274,6 +1314,8 @@ class CookiesTest < ActionController::TestCase
end
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 }
travel 1.hour
@ -1284,6 +1326,8 @@ class CookiesTest < ActionController::TestCase
end
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 }
travel 1.hour
@ -1300,6 +1344,128 @@ class CookiesTest < ActionController::TestCase
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
def assert_cookie_header(expected)
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_digest" => config.action_dispatch.cookies_digest,
"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_report_only" => config.content_security_policy_report_only,
"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)
action_view.default_enforce_utf8 = false
end
if respond_to?(:action_dispatch)
action_dispatch.use_cookies_with_metadata = true
end
else
raise "Unknown version #{target_version.to_s.inspect}"
end

View file

@ -8,3 +8,10 @@
# Don't force requests from old versions of IE to be UTF-8 encoded
# 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
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/read_signed"
assert_equal "signed cookie".inspect, last_response.body
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
test "encrypted cookies rotating multiple encryption keys" do
@ -180,14 +180,14 @@ module ApplicationTests
assert_equal "encrypted cookie".inspect, last_response.body
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"
assert_equal "encrypted cookie".inspect, last_response.body
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

View file

@ -183,7 +183,7 @@ module ApplicationTests
encryptor = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len(cipher)], cipher: cipher)
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
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)
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
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)
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
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)
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
ENV["RAILS_ENV"] = old_rails_env
end
@ -428,7 +428,7 @@ module ApplicationTests
verifier = ActiveSupport::MessageVerifier.new(app.secrets.secret_token)
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
ENV["RAILS_ENV"] = old_rails_env
end