Skip to content

New session not persisting in Rails 5+ #172

Open
@jebentier

Description

@jebentier

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions