CVE-2015-8314: Cleartext Storage of Sensitive Information (CWE-312)
value: The Devise gem before 3.5.4 for Ruby mishandles Remember Me cookies for sessions, which may allow an adversary to obtain unauthorized persistent application access.
CWE description: The product stores sensitive information in cleartext within a resource that might be accessible to another control sphere.
CVSS v3 base score: 7.5
Published: 2023-12-12T17:15Z
Repository: https://github.com/heartcombo/devise
Language: Ruby
Files changed: lib/devise.rb, lib/devise/controllers/rememberable.rb, lib/devise/models/rememberable.rb, lib/devise/models/timeoutable.rb, test/integration/rememberable_test.rb, test/integration/timeoutable_test.rb, test/models/rememberable_test.rb
Commit date: 2016-01-18 14:47:31 +0100
Commit message: Store creation timestamp on remember cookies

Signed-off-by: José Valim <jose.valim@plataformatec.com.br>

Diff: diff --git a/lib/devise.rb b/lib/devise.rb
index d1869651..012fbe45 100644
--- a/lib/devise.rb
+++ b/lib/devise.rb
@@ -114,10 +114,11 @@ module Devise
 
   # The time the user will be remembered without asking for credentials again.
   mattr_accessor :remember_for
   @@remember_for = 2.weeks
 
+  # TODO: extend_remember_period is no longer used
   # If true, extends the user's remember period when remembered via cookie.
   mattr_accessor :extend_remember_period
   @@extend_remember_period = false
 
   # If true, all the remember me tokens are going to be invalidated when the user signs out.
diff --git a/lib/devise/controllers/rememberable.rb b/lib/devise/controllers/rememberable.rb
index 5700fde8..fcd2c0ff 100644
--- a/lib/devise/controllers/rememberable.rb
+++ b/lib/devise/controllers/rememberable.rb
@@ -11,11 +11,11 @@ module Devise
 
       # Remembers the given resource by setting up a cookie
       def remember_me(resource)
         return if env["devise.skip_storage"]
         scope = Devise::Mapping.find_scope!(resource)
-        resource.remember_me!(resource.extend_remember_period)
+        resource.remember_me!
         cookies.signed[remember_key(resource, scope)] = remember_cookie_values(resource)
       end
 
       # Forgets the given resource by deleting a cookie
       def forget_me(resource)
diff --git a/lib/devise/models/rememberable.rb b/lib/devise/models/rememberable.rb
index 62b052fc..9849b3ca 100644
--- a/lib/devise/models/rememberable.rb
+++ b/lib/devise/models/rememberable.rb
@@ -43,35 +43,29 @@ module Devise
 
       def self.required_fields(klass)
         [:remember_created_at]
       end
 
-      # Generate a new remember token and save the record without validations
-      # if remember expired (token is no longer valid) or extend_remember_period is true
-      def remember_me!(extend_period=false)
-        self.remember_token = self.class.remember_token if generate_remember_token?
-        self.remember_created_at = Time.now.utc if generate_remember_timestamp?(extend_period)
+      # TODO: We were used to receive a extend period argument but we no longer do.
+      # Remove this for Devise 4.0.
+      def remember_me!(*)
+        self.remember_token = self.class.remember_token if respond_to?(:remember_token)
+        self.remember_created_at ||= Time.now.utc
         save(validate: false) if self.changed?
       end
 
       # If the record is persisted, remove the remember token (but only if
       # it exists), and save the record without validations.
       def forget_me!
         return unless persisted?
-        self.remember_token = nil if respond_to?(:remember_token=)
+        self.remember_token = nil if respond_to?(:remember_token)
         self.remember_created_at = nil if self.class.expire_all_remember_me_on_sign_out
         save(validate: false)
       end
 
-      # Remember token should be expired if expiration time not overpass now.
-      def remember_expired?
-        remember_created_at.nil? || (remember_expires_at <= Time.now.utc)
-      end
-
-      # Remember token expires at created time + remember_for configuration
       def remember_expires_at
-        remember_created_at + self.class.remember_for
+        self.class.remember_for.from_now
       end
 
       def rememberable_value
         if respond_to?(:remember_token)
           remember_token
@@ -102,41 +96,45 @@ module Devise
       def after_remembered
       end
 
     protected
 
-      def generate_remember_token? #:nodoc:
-        respond_to?(:remember_token) && remember_expired?
-      end
-
-      # Generate a timestamp if extend_remember_period is true, if no remember_token
-      # exists, or if an existing remember token has expired.
-      def generate_remember_timestamp?(extend_period) #:nodoc:
-        extend_period || remember_expired?
-      end
-
       module ClassMethods
         # Create the cookie key using the record id and remember_token
         def serialize_into_cookie(record)
-          [record.to_key, record.rememberable_value]
+          [record.to_key, record.rememberable_value, Time.now.utc]
         end
 
         # Recreate the user based on the stored cookie
-        def serialize_from_cookie(id, remember_token)
-          record = to_adapter.get(id)
-          record if record && !record.remember_expired? &&
-                    Devise.secure_compare(record.rememberable_value, remember_token)
+        def serialize_from_cookie(*args)
+          id, token, generated_at = args
+
+          # The token is only valid if:
+          # 1. we have a date
+          # 2. the current time does not pass the expiry period
+          # 3. there is a record with the given id
+          # 4. the record has a remember_created_at date
+          # 5. the token date is bigger than the remember_created_at
+          # 6. the token matches
+          if generated_at &&
+             (self.remember_for.ago < generated_at) &&
+             (record = to_adapter.get(id)) &&
+             (generated_at > (record.remember_created_at || Time.now).utc) &&
+             Devise.secure_compare(record.rememberable_value, token)
+            record
+          end
         end
 
         # Generate a token checking if one does not already exist in the database.
         def remember_token #:nodoc:
           loop do
             token = Devise.friendly_token
             break token unless to_adapter.find_first({ remember_token: token })
           end
         end
 
+        # TODO: extend_remember_period is no longer used
         Devise::Models.config(self, :remember_for, :extend_remember_period, :rememberable_options, :expire_all_remember_me_on_sign_out)
       end
     end
   end
 end
diff --git a/lib/devise/models/timeoutable.rb b/lib/devise/models/timeoutable.rb
index bdc2abc5..d589e24a 100644
--- a/lib/devise/models/timeoutable.rb
+++ b/lib/devise/models/timeoutable.rb
@@ -24,25 +24,19 @@ module Devise
         []
       end
 
       # Checks whether the user session has expired based on configured time.
       def timedout?(last_access)
-        return false if remember_exists_and_not_expired?
         !timeout_in.nil? && last_access && last_access <= timeout_in.ago
       end
 
       def timeout_in
         self.class.timeout_in
       end
 
       private
 
-      def remember_exists_and_not_expired?
-        return false unless respond_to?(:remember_created_at) && respond_to?(:remember_expired?)
-        remember_created_at && !remember_expired?
-      end
-
       module ClassMethods
         Devise::Models.config(self, :timeout_in)
       end
     end
   end
diff --git a/test/integration/rememberable_test.rb b/test/integration/rememberable_test.rb
index a762eb11..2d82cca3 100644
--- a/test/integration/rememberable_test.rb
+++ b/test/integration/rememberable_test.rb
@@ -2,11 +2,11 @@ require 'test_helper'
 
 class RememberMeTest < ActionDispatch::IntegrationTest
   def create_user_and_remember(add_to_token='')
     user = create_user
     user.remember_me!
-    raw_cookie = User.serialize_into_cookie(user).tap { |a| a.last << add_to_token }
+    raw_cookie = User.serialize_into_cookie(user).tap { |a| a[1] << add_to_token }
     cookies['remember_user_token'] = generate_signed_cookie(raw_cookie)
     user
   end
 
   def generate_signed_cookie(raw_cookie)
@@ -133,11 +133,11 @@ class RememberMeTest < ActionDispatch::IntegrationTest
     assert_redirected_to new_user_session_path
   end
 
   test 'do not remember with expired token' do
     create_user_and_remember
-    swap Devise, remember_for: 0 do
+    swap Devise, remember_for: 0.days do
       get users_path
       assert_not warden.authenticated?(:user)
       assert_redirected_to new_user_session_path
     end
   end
diff --git a/test/integration/timeoutable_test.rb b/test/integration/timeoutable_test.rb
index 1160571c..04302ebe 100644
--- a/test/integration/timeoutable_test.rb
+++ b/test/integration/timeoutable_test.rb
@@ -163,20 +163,10 @@ class SessionTimeoutTest < ActionDispatch::IntegrationTest
       follow_redirect!
       assert_contain 'Session expired!'
     end
   end
 
-  test 'time out not triggered if remembered' do
-    user = sign_in_as_user remember_me: true
-    get expire_user_path(user)
-    assert_not_nil last_request_at
-
-    get users_path
-    assert_response :success
-    assert warden.authenticated?(:user)
-  end
-
   test 'does not crashes when the last_request_at is a String' do
     user = sign_in_as_user
 
     get edit_form_user_path(user, last_request_at: Time.now.utc.to_s)
     get users_path
diff --git a/test/models/rememberable_test.rb b/test/models/rememberable_test.rb
index c69643c6..eb134638 100644
--- a/test/models/rememberable_test.rb
+++ b/test/models/rememberable_test.rb
@@ -11,10 +11,11 @@ class RememberableTest < ActiveSupport::TestCase
 
   test 'remember_me should not generate a new token if using salt' do
     user = create_user
     user.expects(:valid?).never
     user.remember_me!
+    assert user.remember_created_at
   end
 
   test 'forget_me should not clear remember token if using salt' do
     user = create_user
     user.remember_me!
@@ -31,17 +32,49 @@ class RememberableTest < ActiveSupport::TestCase
   end
 
   test 'serialize into cookie' do
     user = create_user
     user.remember_me!
-    assert_equal [user.to_key, user.authenticatable_salt], User.serialize_into_cookie(user)
+    id, token, date = User.serialize_into_cookie(user)
+    assert_equal id, user.to_key
+    assert_equal token, user.authenticatable_salt
+    assert date.is_a?(Time)
   end
 
   test 'serialize from cookie' do
     user = create_user
     user.remember_me!
-    assert_equal user, User.serialize_from_cookie(user.to_key, user.authenticatable_salt)
+    assert_equal user, User.serialize_from_cookie(user.to_key, user.authenticatable_salt, Time.now.utc)
+  end
+
+  test 'serialize from cookie should return nil if no resource is found' do
+    assert_nil resource_class.serialize_from_cookie([0], "123", Time.now.utc)
+  end
+
+  test 'serialize from cookie should return nil if no timestamp' do
+    user = create_user
+    user.remember_me!
+    assert_nil User.serialize_from_cookie(user.to_key, user.authenticatable_salt)
+  end
+
+  test 'serialize from cookie should return nil if timestamp is earlier than token creation' do
+    user = create_user
+    user.remember_me!
+    assert_nil User.serialize_from_cookie(user.to_key, user.authenticatable_salt, 1.day.ago)
+  end
+
+  test 'serialize from cookie should return nil if timestamp is older than remember_for' do
+    user = create_user
+    user.remember_created_at = 1.month.ago
+    user.remember_me!
+    assert_nil User.serialize_from_cookie(user.to_key, user.authenticatable_salt, 3.weeks.ago)
+  end
+
+  test 'serialize from cookie me return nil if is a valid resource with invalid token' do
+    user = create_user
+    user.remember_me!
+    assert_nil User.serialize_from_cookie(user.to_key, "123", Time.now.utc)
   end
 
   test 'raises a RuntimeError if authenticatable_salt is nil or empty' do
     user = User.new
     def user.authenticable_salt; nil; end
@@ -91,113 +124,21 @@ class RememberableTest < ActiveSupport::TestCase
 
     resource.destroy
     resource.forget_me!
   end
 
-  test 'remember is expired if not created at timestamp is set' do
-    assert create_resource.remember_expired?
-  end
-
-  test 'serialize should return nil if no resource is found' do
-    assert_nil resource_class.serialize_from_cookie([0], "123")
-  end
-
-  test 'remember me return nil if is a valid resource with invalid token' do
-    resource = create_resource
-    assert_nil resource_class.serialize_from_cookie([resource.id], "123")
-  end
-
-  test 'remember for should fallback to devise remember for default configuration' do
-    swap Devise, remember_for: 1.day do
-      resource = create_resource
-      resource.remember_me!
-      assert_not resource.remember_expired?
-    end
-  end
-
-  test 'remember expires at should sum date of creation with remember for configuration' do
+  test 'remember expires at uses remember for configuration' do
     swap Devise, remember_for: 3.days do
       resource = create_resource
       resource.remember_me!
       assert_equal 3.days.from_now.to_date, resource.remember_expires_at.to_date
 
       Devise.remember_for = 5.days
       assert_equal 5.days.from_now.to_date, resource.remember_expires_at.to_date
     end
   end
 
-  test 'remember should be expired if remember_for is zero' do
-    swap Devise, remember_for: 0.days do
-      Devise.remember_for = 0.days
-      resource = create_resource
-      resource.remember_me!
-      assert resource.remember_expired?
-    end
-  end
-
-  test 'remember should be expired if it was created before limit time' do
-    swap Devise, remember_for: 1.day do
-      resource = create_resource
-      resource.remember_me!
-      resource.remember_created_at = 2.days.ago
-      resource.save
-      assert resource.remember_expired?
-    end
-  end
-
-  test 'remember should not be expired if it was created within the limit time' do
-    swap Devise, remember_for: 30.days do
-      resource = create_resource
-      resource.remember_me!
-      resource.remember_created_at = (30.days.ago + 2.minutes)
-      resource.save
-      assert_not resource.remember_expired?
-    end
-  end
-
-  test 'if extend_remember_period is false, remember_me! should generate a new timestamp if expired' do
-    swap Devise, remember_for: 5.minutes do
-      resource = create_resource
-      resource.remember_me!(false)
-      assert resource.remember_created_at
-
-      resource.remember_created_at = old = 10.minutes.ago
-      resource.save
-
-      resource.remember_me!(false)
-      assert_not_equal old.to_i, resource.remember_created_at.to_i
-    end
-  end
-
-  test 'if extend_remember_period is false, remember_me! should not generate a new timestamp' do
-    swap Devise, remember_for: 1.year do
-      resource = create_resource
-      resource.remember_me!(false)
-      assert resource.remember_created_at
-
-      resource.remember_created_at = old = 10.minutes.ago.utc
-      resource.save
-
-      resource.remember_me!(false)
-      assert_equal old.to_i, resource.remember_created_at.to_i
-    end
-  end
-
-  test 'if extend_remember_period is true, remember_me! should always generate a new timestamp' do
-    swap Devise, remember_for: 1.year do
-      resource = create_resource
-      resource.remember_me!(true)
-      assert resource.remember_created_at
-
-      resource.remember_created_at = old = 10.minutes.ago
-      resource.save
-
-      resource.remember_me!(true)
-      assert_not_equal old, resource.remember_created_at
-    end
-  end
-
   test 'should have the required_fields array' do
     assert_same_content Devise::Models::Rememberable.required_fields(User), [
       :remember_created_at
     ]
   end

Vulnerable code:         resource.remember_me!(resource.extend_remember_period)
      # Generate a new remember token and save the record without validations
      # if remember expired (token is no longer valid) or extend_remember_period is true
      def remember_me!(extend_period=false)
        self.remember_token = self.class.remember_token if generate_remember_token?
        self.remember_created_at = Time.now.utc if generate_remember_timestamp?(extend_period)
        self.remember_token = nil if respond_to?(:remember_token=)
      # Remember token should be expired if expiration time not overpass now.
      def remember_expired?
        remember_created_at.nil? || (remember_expires_at <= Time.now.utc)
      end

      # Remember token expires at created time + remember_for configuration
        remember_created_at + self.class.remember_for
      def generate_remember_token? #:nodoc:
        respond_to?(:remember_token) && remember_expired?
      end

      # Generate a timestamp if extend_remember_period is true, if no remember_token
      # exists, or if an existing remember token has expired.
      def generate_remember_timestamp?(extend_period) #:nodoc:
        extend_period || remember_expired?
      end

          [record.to_key, record.rememberable_value]
        def serialize_from_cookie(id, remember_token)
          record = to_adapter.get(id)
          record if record && !record.remember_expired? &&
                    Devise.secure_compare(record.rememberable_value, remember_token)
        return false if remember_exists_and_not_expired?
      def remember_exists_and_not_expired?
        return false unless respond_to?(:remember_created_at) && respond_to?(:remember_expired?)
        remember_created_at && !remember_expired?
      end

    raw_cookie = User.serialize_into_cookie(user).tap { |a| a.last << add_to_token }
    swap Devise, remember_for: 0 do
  test 'time out not triggered if remembered' do
    user = sign_in_as_user remember_me: true
    get expire_user_path(user)
    assert_not_nil last_request_at

    get users_path
    assert_response :success
    assert warden.authenticated?(:user)
  end

    assert_equal [user.to_key, user.authenticatable_salt], User.serialize_into_cookie(user)
    assert_equal user, User.serialize_from_cookie(user.to_key, user.authenticatable_salt)
  test 'remember is expired if not created at timestamp is set' do
    assert create_resource.remember_expired?
  end

  test 'serialize should return nil if no resource is found' do
    assert_nil resource_class.serialize_from_cookie([0], "123")
  end

  test 'remember me return nil if is a valid resource with invalid token' do
    resource = create_resource
    assert_nil resource_class.serialize_from_cookie([resource.id], "123")
  end

  test 'remember for should fallback to devise remember for default configuration' do
    swap Devise, remember_for: 1.day do
      resource = create_resource
      resource.remember_me!
      assert_not resource.remember_expired?
    end
  end

  test 'remember expires at should sum date of creation with remember for configuration' do
  test 'remember should be expired if remember_for is zero' do
    swap Devise, remember_for: 0.days do
      Devise.remember_for = 0.days
      resource = create_resource
      resource.remember_me!
      assert resource.remember_expired?
    end
  end

  test 'remember should be expired if it was created before limit time' do
    swap Devise, remember_for: 1.day do
      resource = create_resource
      resource.remember_me!
      resource.remember_created_at = 2.days.ago
      resource.save
      assert resource.remember_expired?
    end
  end

  test 'remember should not be expired if it was created within the limit time' do
    swap Devise, remember_for: 30.days do
      resource = create_resource
      resource.remember_me!
      resource.remember_created_at = (30.days.ago + 2.minutes)
      resource.save
      assert_not resource.remember_expired?
    end
  end

  test 'if extend_remember_period is false, remember_me! should generate a new timestamp if expired' do
    swap Devise, remember_for: 5.minutes do
      resource = create_resource
      resource.remember_me!(false)
      assert resource.remember_created_at

      resource.remember_created_at = old = 10.minutes.ago
      resource.save

      resource.remember_me!(false)
      assert_not_equal old.to_i, resource.remember_created_at.to_i
    end
  end

  test 'if extend_remember_period is false, remember_me! should not generate a new timestamp' do
    swap Devise, remember_for: 1.year do
      resource = create_resource
      resource.remember_me!(false)
      assert resource.remember_created_at

      resource.remember_created_at = old = 10.minutes.ago.utc
      resource.save

      resource.remember_me!(false)
      assert_equal old.to_i, resource.remember_created_at.to_i
    end
  end

  test 'if extend_remember_period is true, remember_me! should always generate a new timestamp' do
    swap Devise, remember_for: 1.year do
      resource = create_resource
      resource.remember_me!(true)
      assert resource.remember_created_at

      resource.remember_created_at = old = 10.minutes.ago
      resource.save

      resource.remember_me!(true)
      assert_not_equal old, resource.remember_created_at
    end
  end

Fixed code:   # TODO: extend_remember_period is no longer used
        resource.remember_me!
      # TODO: We were used to receive a extend period argument but we no longer do.
      # Remove this for Devise 4.0.
      def remember_me!(*)
        self.remember_token = self.class.remember_token if respond_to?(:remember_token)
        self.remember_created_at ||= Time.now.utc
        self.remember_token = nil if respond_to?(:remember_token)
        self.class.remember_for.from_now
          [record.to_key, record.rememberable_value, Time.now.utc]
        def serialize_from_cookie(*args)
          id, token, generated_at = args

          # The token is only valid if:
          # 1. we have a date
          # 2. the current time does not pass the expiry period
          # 3. there is a record with the given id
          # 4. the record has a remember_created_at date
          # 5. the token date is bigger than the remember_created_at
          # 6. the token matches
          if generated_at &&
             (self.remember_for.ago < generated_at) &&
             (record = to_adapter.get(id)) &&
             (generated_at > (record.remember_created_at || Time.now).utc) &&
             Devise.secure_compare(record.rememberable_value, token)
            record
          end
        # TODO: extend_remember_period is no longer used
    raw_cookie = User.serialize_into_cookie(user).tap { |a| a[1] << add_to_token }
    swap Devise, remember_for: 0.days do
    assert user.remember_created_at
    id, token, date = User.serialize_into_cookie(user)
    assert_equal id, user.to_key
    assert_equal token, user.authenticatable_salt
    assert date.is_a?(Time)
    assert_equal user, User.serialize_from_cookie(user.to_key, user.authenticatable_salt, Time.now.utc)
  end

  test 'serialize from cookie should return nil if no resource is found' do
    assert_nil resource_class.serialize_from_cookie([0], "123", Time.now.utc)
  end

  test 'serialize from cookie should return nil if no timestamp' do
    user = create_user
    user.remember_me!
    assert_nil User.serialize_from_cookie(user.to_key, user.authenticatable_salt)
  end

  test 'serialize from cookie should return nil if timestamp is earlier than token creation' do
    user = create_user
    user.remember_me!
    assert_nil User.serialize_from_cookie(user.to_key, user.authenticatable_salt, 1.day.ago)
  end

  test 'serialize from cookie should return nil if timestamp is older than remember_for' do
    user = create_user
    user.remember_created_at = 1.month.ago
    user.remember_me!
    assert_nil User.serialize_from_cookie(user.to_key, user.authenticatable_salt, 3.weeks.ago)
  end

  test 'serialize from cookie me return nil if is a valid resource with invalid token' do
    user = create_user
    user.remember_me!
    assert_nil User.serialize_from_cookie(user.to_key, "123", Time.now.utc)
  test 'remember expires at uses remember for configuration' do