rails / activerecord-session_store

Active Record's Session Store extracted from Rails
MIT License
541 stars 187 forks source link

New session not persisting in Rails 5+ #172

Open jebentier opened 3 years ago

jebentier commented 3 years ago

I've been working on upgrading the platform at my company from Rails 4.2 to Rails 5.x over the past couple months and ran into a rather interesting issue that I've seen referenced symptomatically a few different places. The most prevalent of the symptoms is around the usage of activerecord-session_store and CSRF tokens. Forms that are using CSRF protection on a user with no pre-existing session, are always coming back as invalid because the session store is losing track of the CSRF token stored in the session.

The issue stems from a difference in functionality between the implementations of get_session_model in legacy_support.rb and active_record_store.rb. When encountering a session that has not been stored yet, the LegacySupport implementation creates the session with the ID that was passed, while the ActiveRecordStore implementation generates a fresh session ID and persists that one. The latter introduces a bug when used with rack, because when invoking commit_session, rack makes the assumption that the data that is returned by write_session is what should be persisted as the value of the cookie. And what is returned by ActiveRecord::SessionStore#write_session is the session id that was passed to it.

In the Rails 5 section below, you'll see that the session ID persisted to the store is not the same as the ID set in the cookie.

Proof of Bug

config/application.rb

config.session_store :active_record_store, key: '_my_session_id', domain: :all, ...
config.session_store.session_class = MySessionStore

Rails 4.2 HTTP Response

$> curl -v http://localhost:3000/login
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET /login HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Fri, 12 Feb 2021 14:20:26 GMT
< Connection: close
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Content-Type: text/html; charset=utf-8
< ETag: W/"4e70ba65fd20b4e3bdced38609b450e6"
< Cache-Control: max-age=0, private, must-revalidate
< Set-Cookie: theme=generic; path=/; expires=Sat, 12 Feb 2022 14:20:26 -0000
< Set-Cookie: _my_session_id=046121f0fa6d7443ee0f295cae982a5f; path=/; HttpOnly
< X-Request-Id: 9578b098-aee9-4623-a571-f746171bcec2
< X-Runtime: 6.976695
<
<!DOCTYPE HTML>
<html lang="en">
  <body>
    <form class="form" action="/login" accept-charset="UTF-8" method="post">
      <input name="utf8" type="hidden" value="&#x2713;" />
      <input type="hidden" name="authenticity_token" value="H56r+OOLX5fVNSAEzHQDTiaPB5X5osJtdKSu5kvFv99xrT0Qk/F9lp2ZcmV9K2nnbQGIzpKyQMIBhF3G9HracA==" />
      <input type='submit'>Login</input>
    </form>
  </body>
</html>

Of note is the session ID and the CSRF token:

< Set-Cookie: _my_session_id=046121f0fa6d7443ee0f295cae982a5f; path=/; HttpOnly
<input type="hidden" name="authenticity_token" value="H56r+OOLX5fVNSAEzHQDTiaPB5X5osJtdKSu5kvFv99xrT0Qk/F9lp2ZcmV9K2nnbQGIzpKyQMIBhF3G9HracA==" />

Prying into the form submission shows:

[1] pry(main)> request.cookies['_my_session_id'] = "046121f0fa6d7443ee0f295cae982a5f"
[2] pry(main)> MySessionStore.find_by_session_id("046121f0fa6d7443ee0f295cae982a5f")
#<MySessionStore:0x00007fa526ad33d8 @session_id="2c611b98e7ed27adb11f8fd8fdab4136", @data=nil, @marshaled_data="\x04\b{\aI\"\nflash\x06:\x06ET{\aI\"\fdiscard\x06;\x00T[\x06I\"\vnotice\x06;\x00TI\"\fflashes\x06;\x00T{\x06@\nIC:\x1EActiveSupport::SafeBuffer\"\x1DPlease login to continue\a;\x00T:\x0F@html_safeTI\"\x10_csrf_token\x06;\x00FI\"1Ma8O933f4tHrp9MPopNKczMmkFvoDrOoYQRebPKa4KY=\x06;\x00F", @created_at="2021-02-12T14:24:24.000Z", @updated_at="2021-02-12T14:24:26.000Z">
[3] pry(main)> session = session_object.data
[4] pry(main)> valid_authenticity_token?(session, "Hlm78EMCXueapl/WIADB3lRriCK9w3ZZ1F9o8jx4BwYv9rUHPt28NnEBjNmCk4utZ00YeVXNxfG1WzaezuLnoA==")
true

Rails 5.2 HTTP Response

$> curl -v http://localhost:3000/login
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET /login HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Fri, 12 Feb 2021 14:35:21 GMT
< Connection: close
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< X-Download-Options: noopen
< X-Permitted-Cross-Domain-Policies: none
< Referrer-Policy: strict-origin-when-cross-origin
< Content-Type: text/html; charset=utf-8
< ETag: W/"dbc69522004eae58bd7d1665e570d607"
< Cache-Control: max-age=0, private, must-revalidate
< Set-Cookie: theme=generic; path=/; expires=Sat, 12 Feb 2022 14:35:21 GMT
< Set-Cookie: _my_session_id=273d35c3eaf404ee2116a3de32527741; path=/; HttpOnly
< X-Request-Id: 66942476-4b1c-46d6-b80c-e1ed1b4a1df0
< X-Runtime: 2.075418
<
<!DOCTYPE HTML>
<html lang="en">
  <body>
    <form class="form" action="/login" accept-charset="UTF-8" method="post">
      <input name="utf8" type="hidden" value="&#x2713;" />
      <input type="hidden" name="authenticity_token" value="mb9Z/DgmgJUrhwM1L6YQtzImRUuJCy1Sq2lFeJ6aFgVoMUnraFhQarMI4IWK1nS5GxvgBlVSHQgPVuMSTs99bw==" />
      <input type='submit'>Login</input>
    </form>
  </body>
</html>

Of note is the session ID and the CSRF token:

< Set-Cookie: _my_session_id=273d35c3eaf404ee2116a3de32527741; path=/; HttpOnly
<input type="hidden" name="authenticity_token" value="mb9Z/DgmgJUrhwM1L6YQtzImRUuJCy1Sq2lFeJ6aFgVoMUnraFhQarMI4IWK1nS5GxvgBlVSHQgPVuMSTs99bw==" />

Prying into the form submission shows that the session ID set in the cookie doesn't exist:

[1] pry(main)> request.cookies['_my_session_id'] = "273d35c3eaf404ee2116a3de32527741"
[2] pry(main)> MySessionStore.find_by_session_id("273d35c3eaf404ee2116a3de32527741")
nil

In the logs I found that a different session ID was saved to the session store:

Create session host:localhost (3.0ms)            
       INSERT INTO simple_sessions ( session_id, marshaled_data, created_at, updated_at, saml_session_index )
          VALUES (
            '46f63be739d8db33c674e6594ff8416d',
            '\u0004\b{\u0006I\"\u0010_csrf_token\u0006:\u0006EFI\"1bV5Ma2Omu7NBLIa8sxMrmp71FaQkuBXLkCv0DZfhSxE=\u0006;\\0F',
            '2021-02-12 14:35:21',
            '2021-02-12 14:35:21',
            NULL
          )

In the same pry from above, proof that the session saved to the store is valid:

[3] pry(main)> session_object = MySessionStore.find_by_session_id("46f63be739d8db33c674e6594ff8416d")
#<MySessionStore:0x00007fa405e884a0 @session_id="46f63be739d8db33c674e6594ff8416d", @data=nil, @marshaled_data="\x04\b{\x06I\"\x10_csrf_token\x06:\x06EFI\"1bV5Ma2Omu7NBLIa8sxMrmp71FaQkuBXLkCv0DZfhSxE=\x06;\x00F", @created_at="2021-02-12T14:35:21.000Z", @updated_at="2021-02-12T14:35:21.000Z">
[3] pry(main)> session = session_object.data
[4] pry(main)> valid_authenticity_token?(session, "mb9Z/DgmgJUrhwM1L6YQtzImRUuJCy1Sq2lFeJ6aFgVoMUnraFhQarMI4IWK1nS5GxvgBlVSHQgPVuMSTs99bw==")
true

Workaround

By applying the following patch to ActiveRecordStore#get_session_model I was able to resolve this issue and carry on with the upgrade.

_ = ::ActionDispatch::Session::ActiveRecordStore
module ActionDispatch
  module Session
    class ActiveRecordStore
      def get_session_model(request, id)
        logger.silence_logger do
          model = @@session_class.find_by_session_id(id)
          if !model
            id ||= generate_sid # id = generate_sid
            model = @@session_class.new(:session_id => id, :data => {})
            model.save
          end
          if request.env[ENV_SESSION_OPTIONS_KEY][:id].nil?
            request.env[SESSION_RECORD_KEY] = model
          else
            request.env[SESSION_RECORD_KEY] ||= model
          end
          model
        end
      end
    end
  end
end

I would be more than happy to discuss and work with anyone to implement a permanent fix to this.

alecvn commented 3 years ago

This is still an issue in rails=6.1.3.2 and activerecord-session_store=2.0.0.

kirykr commented 1 year ago

Thank you @jebentier for the workaround solution For those who use Apartment gem you might want to add below line of code

---
def get_session_model(request, id)
  Apartment::Tenant.switch! request.subdomain #gem 'apartment', '~> 2.2'
---
vantran-se commented 1 year ago

Thank you @jebentier for the workaround solution For those who use Apartment gem you might want to add below line of code

---
def get_session_model(request, id)
  Apartment::Tenant.switch! request.subdomain #gem 'apartment', '~> 2.2'
---

this issue is from middlewares order

run rake middleware to see middlewares order

use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::ActiveRecordStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use ActionDispatch::PermissionsPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
use ActiveRecord::Middleware::ShardSelector

in my case is ShardSelector behind ActiveRecordStore if you're using Apartment find apartment middleware that handles switch database

add this line to application.rb and see the magic :D

config.middleware.move_before ActionDispatch::Session::ActiveRecordStore, ActiveRecord::Middleware::ShardSelector