Change MessageEncryptor default serializer to JSON for Rails 7.1

These changes include adding a hybrid serializer class
named JsonWithMarshalFallback in order for existing apps
to have an upgrade path from Marshal to JSON.
This commit is contained in:
Zack 2021-07-20 11:24:51 -04:00
parent 32015b6f36
commit 5d0c2b0dd2
15 changed files with 553 additions and 13 deletions

View File

@ -1,3 +1,9 @@
* Change default serialization format of `MessageEncryptor` from `Marshal` to `JSON` for Rails 7.1.
Existing apps are provided with an upgrade path to migrate to `JSON` as described in `guides/source/upgrading_ruby_on_rails.md`
*Zack Deveau* and *Martin Gingras*
* Add `ActiveSupport::TestCase#stub_const` to stub a constant for the duration of a yield.
*DHH*

View File

@ -66,6 +66,7 @@ module ActiveSupport
autoload :Gzip
autoload :Inflector
autoload :JSON
autoload :JsonWithMarshalFallback
autoload :KeyGenerator
autoload :MessageEncryptor
autoload :MessageVerifier

View File

@ -28,6 +28,7 @@ module ActiveSupport
data
end
end
alias_method :load, :decode
# Returns the class of the error that will be raised when there is an
# error in decoding JSON. Using this method means you won't directly

View File

@ -18,8 +18,11 @@ module ActiveSupport
#
# ActiveSupport::JSON.encode({ team: 'rails', players: '36' })
# # => "{\"team\":\"rails\",\"players\":\"36\"}"
def self.encode(value, options = nil)
Encoding.json_encoder.new(options).encode(value)
class << self
def encode(value, options = nil)
Encoding.json_encoder.new(options).encode(value)
end
alias_method :dump, :encode
end
module Encoding # :nodoc:

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
module ActiveSupport
class JsonWithMarshalFallback
MARSHAL_SIGNATURE = "\x04\x08"
cattr_accessor :fallback_to_marshal_deserialization, instance_accessor: false, default: true
cattr_accessor :use_marshal_serialization, instance_accessor: false, default: true
class << self
def logger
if defined?(Rails) && Rails.respond_to?(:logger)
Rails.logger
else
nil
end
end
def dump(value)
if self.use_marshal_serialization
Marshal.dump(value)
else
JSON.encode(value)
end
end
def load(value)
if self.fallback_to_marshal_deserialization
if value.starts_with?(MARSHAL_SIGNATURE)
logger.warn("JsonWithMarshalFallback: Marshal load fallback occurred.") if logger
Marshal.load(value)
else
JSON.decode(value)
end
else
raise ::JSON::ParserError if value.start_with?(MARSHAL_SIGNATURE)
JSON.decode(value)
end
end
end
end
end

View File

@ -87,6 +87,7 @@ module ActiveSupport
prepend Messages::Rotator::Encryptor
cattr_accessor :use_authenticated_message_encryption, instance_accessor: false, default: false
cattr_accessor :default_message_encryptor_serializer, instance_accessor: false, default: :marshal
class << self
def default_cipher # :nodoc:
@ -142,14 +143,21 @@ module ActiveSupport
# <tt>OpenSSL::Cipher.ciphers</tt>. Default is 'aes-256-gcm'.
# * <tt>:digest</tt> - String of digest to use for signing. Default is
# +SHA1+. Ignored when using an AEAD cipher like 'aes-256-gcm'.
# * <tt>:serializer</tt> - Object serializer to use. Default is +Marshal+.
# * <tt>:serializer</tt> - Object serializer to use. Default is +JSON+.
def initialize(secret, sign_secret = nil, cipher: nil, digest: nil, serializer: nil)
@secret = secret
@sign_secret = sign_secret
@cipher = cipher || self.class.default_cipher
@digest = digest || "SHA1" unless aead_mode?
@verifier = resolve_verifier
@serializer = serializer || Marshal
@serializer = serializer ||
if @@default_message_encryptor_serializer.equal?(:marshal)
Marshal
elsif @@default_message_encryptor_serializer.equal?(:hybrid)
JsonWithMarshalFallback
elsif @@default_message_encryptor_serializer.equal?(:json)
JSON
end
end
# Encrypt and sign a message. We need to sign the message in order to avoid
@ -170,6 +178,14 @@ module ActiveSupport
end
private
def serialize(value)
@serializer.dump(value)
end
def deserialize(value)
@serializer.load(value)
end
def _encrypt(value, **metadata_options)
cipher = new_cipher
cipher.encrypt
@ -179,7 +195,7 @@ module ActiveSupport
iv = cipher.random_iv
cipher.auth_data = "" if aead_mode?
encrypted_data = cipher.update(Messages::Metadata.wrap(@serializer.dump(value), **metadata_options))
encrypted_data = cipher.update(Messages::Metadata.wrap(serialize(value), **metadata_options))
encrypted_data << cipher.final
encoded_encrypted_data = ::Base64.strict_encode64(encrypted_data)
@ -214,8 +230,8 @@ module ActiveSupport
decrypted_data << cipher.final
message = Messages::Metadata.verify(decrypted_data, purpose)
@serializer.load(message) if message
rescue OpenSSLCipherError, TypeError, ArgumentError
deserialize(message) if message
rescue OpenSSLCipherError, TypeError, ArgumentError, ::JSON::ParserError
raise InvalidMessage
end

View File

@ -148,5 +148,32 @@ module ActiveSupport
end
end
end
initializer "active_support.set_fallback_to_marshal_deserialization" do |app|
config.after_initialize do
unless app.config.active_support.fallback_to_marshal_deserialization.nil?
ActiveSupport::JsonWithMarshalFallback.fallback_to_marshal_deserialization =
app.config.active_support.fallback_to_marshal_deserialization
end
end
end
initializer "active_support.set_default_message_encryptor_serializer" do |app|
config.after_initialize do
unless app.config.active_support.default_message_encryptor_serializer.nil?
ActiveSupport::MessageEncryptor.default_message_encryptor_serializer =
app.config.active_support.default_message_encryptor_serializer
end
end
end
initializer "active_support.set_marshal_serialization" do |app|
config.after_initialize do
unless app.config.active_support.use_marshal_serialization.nil?
ActiveSupport::JsonWithMarshalFallback.use_marshal_serialization =
app.config.active_support.use_marshal_serialization
end
end
end
end
end

View File

@ -239,6 +239,137 @@ class MessageEncryptorTest < ActiveSupport::TestCase
end
end
class MessageEncryptorWithHybridSerializerAndMarshalDumpTest < MessageEncryptorTest
def setup
@secret = SecureRandom.random_bytes(32)
@verifier = ActiveSupport::MessageVerifier.new(@secret, serializer: ActiveSupport::MessageEncryptor::NullSerializer)
@data = { some: "data", now: Time.local(2010) }
@encryptor = ActiveSupport::MessageEncryptor.new(@secret)
@default_message_encryptor_serializer = ActiveSupport::MessageEncryptor.default_message_encryptor_serializer
@default_fallback_to_marshal_deserialization = ActiveSupport::JsonWithMarshalFallback.fallback_to_marshal_deserialization
ActiveSupport::MessageEncryptor.default_message_encryptor_serializer = :hybrid
end
def teardown
ActiveSupport::MessageEncryptor.default_message_encryptor_serializer = @default_message_encryptor_serializer
ActiveSupport::JsonWithMarshalFallback.fallback_to_marshal_deserialization = @default_fallback_to_marshal_deserialization
super
end
def test_backwards_compatibility_decrypt_previously_marshal_serialized_messages_when_fallback_to_marshal_deserialization_is_true
secret = "\xB7\xF0\xBCW\xB1\x18`\xAB\xF0\x81\x10\xA4$\xF44\xEC\xA1\xDC\xC1\xDDD\xAF\xA9\xB8\x14\xCD\x18\x9A\x99 \x80)"
data = "this_is_data"
marshal_message = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: Marshal).encrypt_and_sign(data)
assert_equal data, ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm").decrypt_and_verify(marshal_message)
end
def test_failure_to_decrypt_marshal_serialized_messages_when_fallback_to_marshal_deserialization_is_false
ActiveSupport::JsonWithMarshalFallback.fallback_to_marshal_deserialization = false
secret = "\xB7\xF0\xBCW\xB1\x18`\xAB\xF0\x81\x10\xA4$\xF44\xEC\xA1\xDC\xC1\xDDD\xAF\xA9\xB8\x14\xCD\x18\x9A\x99 \x80)"
data = "this_is_data"
marshal_message = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: Marshal).encrypt_and_sign(data)
assert_raise(ActiveSupport::MessageEncryptor::InvalidMessage) do
ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm").decrypt_and_verify(marshal_message)
end
end
end
class MessageEncryptorWithJsonSerializerTest < MessageEncryptorTest
def setup
@secret = SecureRandom.random_bytes(32)
@verifier = ActiveSupport::MessageVerifier.new(@secret, serializer: ActiveSupport::MessageEncryptor::NullSerializer)
@data = { "some" => "data", "now" => Time.local(2010) }
@encryptor = ActiveSupport::MessageEncryptor.new(@secret)
@default_message_encryptor_serializer = ActiveSupport::MessageEncryptor.default_message_encryptor_serializer
ActiveSupport::MessageEncryptor.default_message_encryptor_serializer = :json
end
def teardown
ActiveSupport::MessageEncryptor.default_message_encryptor_serializer = @default_message_encryptor_serializer
super
end
def test_backwards_compat_for_64_bytes_key
# 64 bit key
secret = ["3942b1bf81e622559ed509e3ff274a780784fe9e75b065866bd270438c74da822219de3156473cc27df1fd590e4baf68c95eeb537b6e4d4c5a10f41635b5597e"].pack("H*")
# Encryptor with 32 bit key, 64 bit secret for verifier
encryptor = ActiveSupport::MessageEncryptor.new(secret[0..31], secret)
# Message generated with 64 bit key using the JSON serializer
message = "YWZPOVBzcS8ycGZpVWpjWWU5NXZoanRtRGkzTVRjZk16UUpuRE9wa0pNNGI0dGxMRU5RdC94ZVhiQzUwYktFL2NEUjBnUm1QSlRDQ1RrMGlvakVZcGc9PS0tbXROMVdDUnNxWlRxMXA4dEtjWi9oZz09--3489b03e85aa14d8ed8cdd455507246f2e2884ae"
assert_equal "data", encryptor.decrypt_and_verify(message)["some"]
end
def test_backwards_compatibility_decrypt_previously_encrypted_messages_without_metadata
secret = "\xB7\xF0\xBCW\xB1\x18`\xAB\xF0\x81\x10\xA4$\xF44\xEC\xA1\xDC\xC1\xDDD\xAF\xA9\xB8\x14\xCD\x18\x9A\x99 \x80)"
encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm")
# Message generated with MessageEncryptor given JSON as the serializer.
encrypted_message = "NIevxlF4gFUETmYTm3GJ--SIlwA5xxwpqNtiiv--woA9eLuVMbmapIDGDj7HQQ=="
assert_equal "Ruby on Rails", encryptor.decrypt_and_verify(encrypted_message)
end
def test_on_rotation_is_called_and_returns_modified_messages
older_message = ActiveSupport::MessageEncryptor.new(secrets[:older], "older sign").encrypt_and_sign({ encoded: "message" })
encryptor = ActiveSupport::MessageEncryptor.new(@secret)
encryptor.rotate secrets[:old]
encryptor.rotate secrets[:older], "older sign"
rotated = false
message = encryptor.decrypt_and_verify(older_message, on_rotation: proc { rotated = true })
assert_equal({ "encoded" => "message" }, message)
assert rotated
end
def test_on_rotation_can_be_passed_at_the_constructor_level
older_message = ActiveSupport::MessageEncryptor.new(secrets[:older], "older sign").encrypt_and_sign({ encoded: "message" })
rotated = rotated = false # double assigning to suppress "assigned but unused variable" warning
encryptor = ActiveSupport::MessageEncryptor.new(@secret, on_rotation: proc { rotated = true })
encryptor.rotate secrets[:older], "older sign"
assert_changes(:rotated, from: false, to: true) do
message = encryptor.decrypt_and_verify(older_message)
assert_equal({ "encoded" => "message" }, message)
end
end
def test_on_rotation_option_takes_precedence_over_the_one_given_in_constructor
older_message = ActiveSupport::MessageEncryptor.new(secrets[:older], "older sign").encrypt_and_sign({ encoded: "message" })
rotated = rotated = false # double assigning to suppress "assigned but unused variable" warning
encryptor = ActiveSupport::MessageEncryptor.new(@secret, on_rotation: proc { rotated = true })
encryptor.rotate secrets[:older], "older sign"
assert_changes(:rotated, from: false, to: "Yes") do
message = encryptor.decrypt_and_verify(older_message, on_rotation: proc { rotated = "Yes" })
assert_equal({ "encoded" => "message" }, message)
end
end
end
class MessageEncryptorWithHybridSerializerAndWithoutMarshalDumpTest < MessageEncryptorWithJsonSerializerTest
def setup
@secret = SecureRandom.random_bytes(32)
@verifier = ActiveSupport::MessageVerifier.new(@secret, serializer: ActiveSupport::MessageEncryptor::NullSerializer)
@data = { "some" => "data", "now" => Time.local(2010) }
@encryptor = ActiveSupport::MessageEncryptor.new(@secret)
@default_message_encryptor_serializer = ActiveSupport::MessageEncryptor.default_message_encryptor_serializer
@default_use_marshal_serialization = ActiveSupport::JsonWithMarshalFallback.use_marshal_serialization
ActiveSupport::MessageEncryptor.default_message_encryptor_serializer = :hybrid
ActiveSupport::JsonWithMarshalFallback.use_marshal_serialization = false
end
def teardown
ActiveSupport::MessageEncryptor.default_message_encryptor_serializer = @default_message_encryptor_serializer
ActiveSupport::JsonWithMarshalFallback.use_marshal_serialization = @default_use_marshal_serialization
super
end
end
class MessageEncryptorMetadataTest < ActiveSupport::TestCase
include SharedMessageMetadataTests
@ -272,3 +403,10 @@ class MessageEncryptorMetadataJSONTest < MessageEncryptorMetadataTest
{ serializer: MessageEncryptorTest::JSONSerializer.new }
end
end
class MessageEncryptorMetadataJsonWithMarshalFallbackTest < MessageEncryptorMetadataTest
private
def encryptor_options
{ serializer: ActiveSupport::JsonWithMarshalFallback }
end
end

View File

@ -62,6 +62,7 @@ Below are the default values associated with each target version. In cases of co
- [`config.action_dispatch.default_headers`](#config-action-dispatch-default-headers): `{ "X-Frame-Options" => "SAMEORIGIN", "X-XSS-Protection" => "0", "X-Content-Type-Options" => "nosniff", "X-Permitted-Cross-Domain-Policies" => "none", "Referrer-Policy" => "strict-origin-when-cross-origin" }`
- [`config.add_autoload_paths_to_load_path`](#config-add-autoload-paths-to-load-path): `false`
- [`config.active_support.default_message_encryptor_serializer`](#config-active-support-default-message-encryptor-serializer): `:json`
#### Default Values for Target Version 7.0
@ -1868,6 +1869,35 @@ The default value depends on the `config.load_defaults` target version:
| (original) | `false` |
| 6.1 | `true` |
#### `config.active_support.default_message_encryptor_serializer`
Specifies what serializer the `MessageEncryptor` class will use by default.
Options are `:json`, `:hybrid` and `:marshal`. `:hybrid` uses the `JsonWithMarshalFallback` class.
The default value depends on the `config.load_defaults` target version:
| Starting with version | The default value is |
| --------------------- | -------------------- |
| (original) | `:marshal` |
| 7.1 | `:json` |
#### `config.active_support.fallback_to_marshal_deserialization`
Specifies if the `ActiveSupport::JsonWithMarshalFallback` class will fallback to `Marshal` when it encounters a `::JSON::ParserError`.
Defaults to `true`.
#### `config.active_support.use_marshal_serialization`
Specifies if the `ActiveSupport::JsonWithMarshalFallback` class will use `Marshal` to serialize payloads.
If this is set to false, it will use `JSON` to serialize payloads.
Used to help migrate apps from `Marshal` to `JSON` as the default serializer for the `MessageEncryptor` class.
Defaults to `true`.
### Configuring Active Job
`config.active_job` provides the following configuration options:

View File

@ -90,6 +90,85 @@ size of the `bootsnap` cache for the others.
Application controllers that inherit from `ActiveStorage::BaseController` and use streaming to implement custom file serving logic must now explicitly include the `ActiveStorage::Streaming` module.
### New `ActiveSupport::MessageEncryptor` default serializer
As of Rails 7.1, the default serializer in use by the `MessageEncryptor` is `JSON`.
This offers a more secure alternative to the current default serializer.
The `MessageEncryptor` offers the ability to migrate the default serializer from `Marshal` to `JSON`.
If you would like to ignore this change in existing applications, set the following: `config.active_support.default_message_encryptor_serializer = :marshal`.
In order to roll out the new default when upgrading from `7.0` to `7.1`, there are three configuration variables to keep in mind.
```
config.active_support.default_message_encryptor_serializer
config.active_support.fallback_to_marshal_deserialization
config.active_support.use_marshal_serialization
```
`default_message_encryptor_serializer` defaults to `:json` as of `7.1` but it offers both a `:hybrid` and `:marshal` option.
In order to migrate an older deployment to `:json`, first ensure that the `default_message_encryptor_serializer` is set to `:marshal`.
```ruby
# config/application.rb
config.load_defaults 7.0
config.active_support.default_message_encryptor_serializer = :marshal
```
Once this is deployed on all Rails processes, set `default_message_encryptor_serializer` to `:hybrid` to begin using the
`ActiveSupport::JsonWithMarshalFallback` class as the serializer. The defaults for this class are to use `Marshal`
as the serializer and to allow the deserialisation of both `Marshal` and `JSON` serialized payloads.
```ruby
config.load_defaults 7.0
config.active_support.default_message_encryptor_serializer = :hybrid
```
Once this is deployed on all Rails processes, set the following configuration options in order to stop the
`ActiveSupport::JsonWithMarshalFallback` class from using `Marshal` to serialize new payloads.
```ruby
# config/application.rb
config.load_defaults 7.0
config.active_support.default_message_encryptor_serializer = :hybrid
config.active_support.use_marshal_serialization = false
```
Allow this configuration to run on all processes for a considerable amount of time.
`ActiveSupport::JsonWithMarshalFallback` logs the following each time the `Marshal` fallback
is used:
```
JsonWithMarshalFallback: Marshal load fallback occurred.
```
Once those message stop appearing in your logs and you're confident that all `MessageEncryptor`
payloads in transit are `JSON` serialized, the following configuration options will disable the
Marshal fallback in `ActiveSupport::JsonWithMarshalFallback`.
```ruby
# config/application.rb
config.load_defaults 7.0
config.active_support.default_message_encryptor_serializer = :hybrid
config.active_support.use_marshal_serialization = false
config.active_support.fallback_to_marshal_deserialization = false
```
If all goes well, you should now be safe to migrate the Message Encryptor from
`ActiveSupport::JsonWithMarshalFallback` to `ActiveSupport::JSON`.
To do so, simply swap the `:hybrid` serializer for the `:json` serializer.
```ruby
# config/application.rb
config.load_defaults 7.0
config.active_support.default_message_encryptor_serializer = :json
```
Alternatively, you could load defaults for 7.1
```ruby
# config/application.rb
config.load_defaults 7.1
```
Upgrading from Rails 6.1 to Rails 7.0
-------------------------------------

View File

@ -267,6 +267,10 @@ module Rails
"Referrer-Policy" => "strict-origin-when-cross-origin"
}
end
if respond_to?(:active_support)
active_support.default_message_encryptor_serializer = :json
end
else
raise "Unknown version #{target_version.to_s.inspect}"
end

View File

@ -3169,6 +3169,69 @@ module ApplicationTests
assert_equal true, Rails.application.config.rake_eager_load
end
test "ActiveSupport::JsonWithMarshalFallback.fallback_to_marshal_deserialization is true by default" do
app "development"
assert_equal true, ActiveSupport::JsonWithMarshalFallback.fallback_to_marshal_deserialization
end
test "ActiveSupport::JsonWithMarshalFallback.fallback_to_marshal_deserialization can be configured via config.active_support.fallback_to_marshal_deserialization" do
remove_from_config '.*config\.load_defaults.*\n'
app_file "config/initializers/fallback_to_marshal_deserialization.rb", <<-RUBY
Rails.application.config.active_support.fallback_to_marshal_deserialization = false
RUBY
app "development"
assert_equal false, ActiveSupport::JsonWithMarshalFallback.fallback_to_marshal_deserialization
end
test "ActiveSupport::JsonWithMarshalFallback.use_marshal_serialization is true by default" do
app "development"
assert_equal true, ActiveSupport::JsonWithMarshalFallback.use_marshal_serialization
end
test "ActiveSupport::JsonWithMarshalFallback.use_marshal_serialization can be configured via config.active_support.use_marshal_serialization" do
remove_from_config '.*config\.load_defaults.*\n'
app_file "config/initializers/use_marshal_serialization.rb", <<-RUBY
Rails.application.config.active_support.use_marshal_serialization = false
RUBY
app "development"
assert_equal false, ActiveSupport::JsonWithMarshalFallback.use_marshal_serialization
end
test "ActiveSupport::MessageEncryptor.default_message_encryptor_serializer is :json by default" do
app "development"
assert_equal :json, ActiveSupport::MessageEncryptor.default_message_encryptor_serializer
end
test "ActiveSupport::MessageEncryptor.default_message_encryptor_serializer is :marshal by default for upgraded apps" do
remove_from_config '.*config\.load_defaults.*\n'
add_to_config 'config.load_defaults "6.1"'
app "development"
assert_equal :marshal, ActiveSupport::MessageEncryptor.default_message_encryptor_serializer
end
test "ActiveSupport::MessageEncryptor.default_message_encryptor_serializer can be configured via config.active_support.default_message_encryptor_serializer" do
remove_from_config '.*config\.load_defaults.*\n'
app_file "config/initializers/default_message_encryptor_serializer.rb", <<-RUBY
Rails.application.config.active_support.default_message_encryptor_serializer = :hybrid
RUBY
app "development"
assert_equal :hybrid, ActiveSupport::MessageEncryptor.default_message_encryptor_serializer
end
test "unknown_asset_fallback is false by default" do
app "development"

View File

@ -158,8 +158,8 @@ module ApplicationTests
second_secret = Rails.application.key_generator.generate_key("second", 32)
::TestEncryptors = Class.new do
class_attribute :first_gcm, default: ActiveSupport::MessageEncryptor.new(first_secret, cipher: "aes-256-gcm")
class_attribute :second_gcm, default: ActiveSupport::MessageEncryptor.new(second_secret, cipher: "aes-256-gcm")
class_attribute :first_gcm, default: ActiveSupport::MessageEncryptor.new(first_secret, cipher: "aes-256-gcm", serializer: Marshal)
class_attribute :second_gcm, default: ActiveSupport::MessageEncryptor.new(second_secret, cipher: "aes-256-gcm", serializer: Marshal)
end
config.action_dispatch.use_authenticated_cookie_encryption = true
@ -175,6 +175,77 @@ module ApplicationTests
require "#{app_path}/config/environment"
encryptor = ActiveSupport::MessageEncryptor.new(app.key_generator.generate_key("salt", 32), cipher: "aes-256-gcm", serializer: Marshal)
get "/foo/write_raw_cookie_one"
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, purpose: "cookie.encrypted_cookie")
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, purpose: "cookie.encrypted_cookie")
end
test "encrypted cookies rotating multiple encryption keys with cookies serializer as json" do
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get ':controller(/:action)'
post ':controller(/:action)'
end
RUBY
controller :foo, <<-RUBY
class FooController < ActionController::Base
protect_from_forgery with: :null_session
def write_raw_cookie_one
cookies[:encrypted_cookie] = TestEncryptors.first_gcm.encrypt_and_sign("encrypted cookie")
head :ok
end
def write_raw_cookie_two
cookies[:encrypted_cookie] = TestEncryptors.second_gcm.encrypt_and_sign("encrypted cookie")
head :ok
end
def read_encrypted
render plain: cookies.encrypted[:encrypted_cookie].inspect
end
def read_raw_cookie
render plain: cookies[:encrypted_cookie]
end
end
RUBY
add_to_config <<-RUBY
first_secret = Rails.application.key_generator.generate_key("first", 32)
second_secret = Rails.application.key_generator.generate_key("second", 32)
::TestEncryptors = Class.new do
class_attribute :first_gcm, default: ActiveSupport::MessageEncryptor.new(first_secret, cipher: "aes-256-gcm", serializer: ActiveSupport::JSON)
class_attribute :second_gcm, default: ActiveSupport::MessageEncryptor.new(second_secret, cipher: "aes-256-gcm", serializer: ActiveSupport::JSON)
end
config.action_dispatch.use_authenticated_cookie_encryption = true
config.action_dispatch.encrypted_cookie_cipher = "aes-256-gcm"
config.action_dispatch.authenticated_encrypted_cookie_salt = "salt"
config.action_dispatch.cookies_serializer = :json
config.action_dispatch.cookies_rotations.tap do |cookies|
cookies.rotate :encrypted, first_secret
cookies.rotate :encrypted, second_secret
end
RUBY
require "#{app_path}/config/environment"
encryptor = ActiveSupport::MessageEncryptor.new(app.key_generator.generate_key("salt", 32), cipher: "aes-256-gcm")
get "/foo/write_raw_cookie_one"

View File

@ -136,7 +136,59 @@ module ApplicationTests
assert_equal '"1"', last_response.body
end
test "session using encrypted cookie store" do
test "session using encrypted cookie store with json serializer" do
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get ':controller(/:action)'
end
RUBY
controller :foo, <<-RUBY
class FooController < ActionController::Base
def write_session
session[:foo] = 1
head :ok
end
def read_session
render plain: session[:foo]
end
def read_encrypted_cookie
render plain: cookies.encrypted[:_myapp_session]['foo']
end
def read_raw_cookie
render plain: cookies[:_myapp_session]
end
end
RUBY
add_to_config <<-RUBY
# Enable AEAD cookies
config.action_dispatch.use_authenticated_cookie_encryption = true
config.action_dispatch.cookies_serializer = :json
RUBY
require "#{app_path}/config/environment"
get "/foo/write_session"
get "/foo/read_session"
assert_equal "1", last_response.body
get "/foo/read_encrypted_cookie"
assert_equal "1", last_response.body
cipher = "aes-256-gcm"
secret = app.key_generator.generate_key("authenticated encrypted cookie")
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, purpose: "cookie._myapp_session")["foo"]
end
test "session using encrypted cookie store with marshal serializer" do
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get ':controller(/:action)'
@ -182,7 +234,7 @@ module ApplicationTests
cipher = "aes-256-gcm"
secret = app.key_generator.generate_key("authenticated encrypted cookie")
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, serializer: Marshal)
get "/foo/read_raw_cookie"
assert_equal 1, encryptor.decrypt_and_verify(last_response.body, purpose: "cookie._myapp_session")["foo"]
@ -233,7 +285,7 @@ module ApplicationTests
cipher = "aes-256-gcm"
secret = app.key_generator.generate_key("authenticated encrypted cookie")
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, serializer: Marshal)
get "/foo/read_raw_cookie"
assert_equal 1, encryptor.decrypt_and_verify(last_response.body, purpose: "cookie._myapp_session")["foo"]
@ -305,7 +357,7 @@ module ApplicationTests
cipher = "aes-256-gcm"
secret = app.key_generator.generate_key("authenticated encrypted cookie")
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, serializer: Marshal)
get "/foo/read_raw_cookie"
assert_equal 2, encryptor.decrypt_and_verify(last_response.body, purpose: "cookie._myapp_session")["foo"]

View File

@ -39,6 +39,13 @@ class Rails::Command::SecretsCommandTest < ActiveSupport::TestCase
end
test "edit secrets" do
# Use expected default MessageEncryptor serializer for Rails < 7.1 to be compatible with hardcoded secrets.yml.enc
add_to_config <<-RUBY
config.active_support.default_message_encryptor_serializer = :marshal
RUBY
require "#{app_path}/config/environment"
prevent_deprecation
# Run twice to ensure encrypted secrets can be reread after first edit pass.