activerecord-session_store
activerecord-session_store copied to clipboard
New session not persisting in Rails 5+
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="✓" />
<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="✓" />
<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.
This is still an issue in rails=6.1.3.2
and activerecord-session_store=2.0.0
.
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'
---
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