Hegwin.Me

The hydra-universe twisting its body scaled in stars.

Rotate cookies when upgrading to Rails 7

Rails 7升级过程中针对cookie一系列变化的应对策略

Rails 7 brought a number of new features as well as breaking changes, which made the upgrade from 6.1 more troublesome than the previous one. Rails modified the default cookie serilaizer and the default encryption method, causing incompatibility between the old and new cookies, so the upgrade needs to be done in steps to ensure the best user experience.

Background

HTTP itself is stateless and requires the use of cookies to preserve state. Cookies are text files stored on the client side that contain a name and value, as well as a number of other optional attributes such as expiration date, domain and path. By using cookies, web applications can retain user state for the duration of the same browser session.

How cookies work in Rails

There are several ways to manipulate cookies in Rails. A normal cookie can be set in the controller with something like cookies[:user_id] = 1, while for signed or encrypted cookies the following is used:

# Signed cookies
cookies.signed[:user_id] = 1
# Encrypted cookies
cookies.encrypted[:user_id] = 1

The signed cookie uses secrets.secret_key_base to sign the cookie, which guarantees the integrity of the cookie content, but not the confidentiality of the cookie. An encrypted cookie encrypts the content and guarantees confidentiality.

cookies-in-rails.png

If you set an encrypted cookie in the controller, you can only see the encrypted cookie in the browser, but not the exact contents of the cookie, while using cookies.encrypted[:user_id] in the controller, you can get the decrypted contents.

Changes in Rails 7 involving cookies

After upgrading to Rails 7, the cookie serializer switched from Marshal to JSON. Also, digest class switched from SHA1 to SHA256, which means that any information previously encrypted by Rails will be invalidated. This may invalidate the user's login status or other information, so we need some strategy to make this switch indifferent to the user.

Marshal and JSON are the two serialization methods in Ruby. Marshal is the serialization method natively supported by Ruby, but it has the following drawbacks:

  1. It is not secure enough and can lead to remote code execution by constructing malicious objects;
  2. It is not flexible enough, and can only be deserialized in a Ruby environment.

Therefore, Rails 7 has been upgraded to JSON serialization approach to improving the security and cross-language compatibility of the application.

To address this, we can follow these steps to make the switch:

  1. Complete the upgrade to Rails 7 and deploy it so that the other changes can take effect in the production environment, but configure the serializer to still use Marshal serialization:

    Rails.application.config.action_dispatch.cookies_serializer = :marshal
    
  2. Using a serializer in Hybrid mode, Rails 7 provides a serializer configuration item called :hybrid that automatically serializes past cookies to JSON on the next user visit:

    Rails.application.config.action_dispatch.cookies_serializer = :hybrid
    
  3. After a certain amount of time, switch to JSON serialization completely, but you can also use :hybrid as the final configuration. If you intend to use JSON serialization completely, then you can remove Rails.application.config.action_dispatch.cookies_serializer to use the default 7.0 default configuration.

Note that since cookies are no longer serialized by Marshal, this means that you can no longer put some complex Ruby objects into cookies. In general, using Hash, String and Numeric would be safer options.

Solution to Digest Class changes

Due to security concerns, Rails 7 switched from SHA1 to SHA256 for the default digest class.

# Change the digest class for the key generators to `OpenSSL::Digest::SHA256`.
# Changing this default means invalidate all encrypted messages generated by
# your application and, all the encrypted cookies. Only change this after you
# rotated all the messages using the key rotator.
#
# See upgrading guide for more information on how to build a rotator.
# https://guides.rubyonrails.org/v7.0/upgrading_ruby_on_rails.html
Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256

This means that any information previously encrypted by Rails will be invalidated. Therefore, a cookie rotator needs to be written to solve this problem. Here is the official sample code given below:

# config/initializers/cookie_rotator.rb
Rails.application.config.after_initialize do
  Rails.application.config.action_dispatch.cookies_rotations.tap do |cookies|
    authenticated_encrypted_cookie_salt = Rails.application.config.action_dispatch.authenticated_encrypted_cookie_salt
    signed_cookie_salt = Rails.application.config.action_dispatch.signed_cookie_salt

    secret_key_base = Rails.application.secret_key_base

    key_generator = ActiveSupport::KeyGenerator.new(
      secret_key_base, iterations: 1000, hash_digest_class: OpenSSL::Digest::SHA1
    )
    key_len = ActiveSupport::MessageEncryptor.key_len

    old_encrypted_secret = key_generator.generate_key(authenticated_encrypted_cookie_salt, key_len)
    old_signed_secret = key_generator.generate_key(signed_cookie_salt)

    cookies.rotate :encrypted, old_encrypted_secret
    cookies.rotate :signed, old_signed_secret
  end
end

The above code switches the encryption method from SHA1 to SHA256 and implements rotation of the old cookie.

We rarely do the cookie encryption manually ourselves in app development, more commonly we use the gem devise and devise will use Rails.application.secret_ key_base for user login-related cookie encryption, and the above code can be placed in the initializer to rotate the old cookie and avoid the need to log in again. However, you also need to pay attention to whether other gem use ActiveSupport::KeyGenerator related code, if so, you also need to perform similar rotation.

References

  1. RailsでセッションとCookieを操作する方法

  2. Migrating Rails cookies to the new JSON serializer

  3. Rails 7's new_framework_defaults_7_0 project

< Back