Compare commits

...
7 Commits
Author SHA1 Message Date
Jeremy Daer 47c3f25cf6 Merge pull request #378 from basecamp/update-ca-certificates-1.5
Update the trusted CA certificates (1.5.6)
2026-10-21 14:02:06 -07:00
Jeremy Daer 2e83b56860 Update the trusted CA certificates
Name ca-certificates in the base stage's apt install so the image takes the
distribution's current package rather than the one in the base image, and fail
the build if either withdrawn root (emSign ECC Root CA - G3, AAA Certificate
Services) is still in the store Ruby's OpenSSL loads.
2026-10-21 13:01:25 -07:00
Jeremy Daer be9f2b9b51 Merge pull request #377 from basecamp/backport/session-checks-1.5.5
End sessions reliably when a password changes (1.5.5)
2026-10-20 21:02:09 -07:00
Jeremy Daer 20fbd3865c Check a cable connection's session while it's open (#376)
* Check a cable connection's session while it's open

Signing out or changing the password ends sessions and then disconnects
their connections. A connection that found its session just before isn't
subscribed to its disconnects yet, so it missed that and stayed open.

Each connection now looks its session up every minute while it's open, and
closes once the session is gone, asking the browser to reconnect so one
holding a current session comes back. That also covers any other way a
connection misses the disconnect, such as a subscription that takes long to
register, and a lookup that fails is simply tried again.

* Check the session on the server's heartbeat rather than a timer of its own

A connection's own timer could outlive it: closing can run before opening finishes, or a channel's teardown can fail before the connection's. The heartbeat only reaches connections that are open, so nothing is left to stop.

* Skip the session check on a rejected connection

A rejected connection stays registered until its socket closes, and has no session to check, so its heartbeat mustn't fail and stop the heartbeat reaching the connections after it.

(cherry picked from commit ce7b5d4ea0)
2026-10-20 20:45:38 -07:00
Jeremy Daer 9ca680de02 Sign the browser out when its new session is refused after a password change
If the password changes again between this browser's change and its new session, that session is refused, and the browser is sent to sign in instead of an error.

(cherry picked from commit a476cbfa24)
2026-10-20 20:45:36 -07:00
Jeremy Daer 09402c7752 Read the password digest past the query cache
A digest cached earlier in the request would let the check pass on a password that has since changed.

(cherry picked from commit c3faccb0f4)
2026-10-20 20:45:36 -07:00
Jeremy Daer 48da2b4e4e Refuse a session for a password that changed after it was checked
A password change ends every session, but a sign-in or sign-in link checked
against the old password just before the change could still save its session
just after, and that session outlived the change.

The session now checks, as it's saved, that the user's password is still the
one the request loaded. The save's transaction holds SQLite's write lock from
the start, so a change commits either before the check, which fails it, or
after the insert, and ends this session with the others. Sessions start
through the user's association so the check sees the user that was
authenticated, and a refused sign-in is answered like a wrong password.

(cherry picked from commit 19319a1cff)
2026-10-20 20:45:36 -07:00
13 changed files with 167 additions and 8 deletions

No files matched your search

+14 -1
View File
@@ -9,10 +9,23 @@ WORKDIR /rails
# Install base packages # Install base packages
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \ apt-get install --no-install-recommends -y ca-certificates curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archive rm -rf /var/lib/apt/lists /var/cache/apt/archive
# Naming ca-certificates above takes the distribution's current trusted roots rather than the
# ones frozen into the base image. Fail the build if either of these withdrawn roots is still
# where Ruby's OpenSSL looks for trusted certificates.
RUN ruby -ropenssl -e ' \
file = ENV[OpenSSL::X509::DEFAULT_CERT_FILE_ENV] || OpenSSL::X509::DEFAULT_CERT_FILE; \
dir = ENV[OpenSSL::X509::DEFAULT_CERT_DIR_ENV] || OpenSSL::X509::DEFAULT_CERT_DIR; \
pems = [file, *Dir.glob(File.join(dir, "*"))].select { |path| File.file?(path) } \
.flat_map { |path| File.read(path).scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m) }; \
subjects = pems.map { |pem| OpenSSL::X509::Certificate.new(pem).subject.to_s }.uniq; \
abort "Only #{subjects.size} roots in #{file} and #{dir}" if subjects.size < 100; \
withdrawn = subjects.grep(%r{/CN=(emSign ECC Root CA - G3|AAA Certificate Services)\z}); \
abort "Withdrawn roots still trusted: #{withdrawn}" if withdrawn.any?'
# Set production environment # Set production environment
ENV RAILS_ENV="production" \ ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \ BUNDLE_DEPLOYMENT="1" \
+25 -2
View File
@@ -2,19 +2,42 @@ module ApplicationCable
class Connection < ActionCable::Connection::Base class Connection < ActionCable::Connection::Base
include Authentication::SessionLookup include Authentication::SessionLookup
SESSION_CHECK_INTERVAL = 1.minute
identified_by :current_user identified_by :current_user
def connect def connect
self.current_user = find_verified_user self.current_user = find_verified_user
@session_checked_at = Time.now
end
# Signing out or changing the password ends sessions and then disconnects their connections, but a connection can
# miss that: one that found its session just before isn't listening yet. So on the server's heartbeat, which
# reaches every open connection, each one looks its session up again now and then, and closes once it's gone. A
# rejected connection has no session, and stays registered until its socket closes.
def beat
super
check_session if @session_checked_at&.before?(SESSION_CHECK_INTERVAL.ago)
end end
private private
def find_verified_user def find_verified_user
if verified_session = find_session_by_cookie if @verified_session = find_session_by_cookie
verified_session.user @verified_session.user
else else
reject_unauthorized_connection reject_unauthorized_connection
end end
end end
def check_session
@session_checked_at = Time.now
perform_work -> { close_unless_session_remains }, :call
end
def close_unless_session_remains
unless Session.exists?(@verified_session.id)
close reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized]
end
end
end end
end end
+1 -1
View File
@@ -57,7 +57,7 @@ module Authentication
end end
def start_new_session_for(user) def start_new_session_for(user)
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
authenticated_as session authenticated_as session
end end
end end
@@ -11,5 +11,7 @@ class Sessions::TransfersController < ApplicationController
else else
head :bad_request head :bad_request
end end
rescue ActiveRecord::RecordInvalid
head :bad_request
end end
end end
+2
View File
@@ -14,6 +14,8 @@ class SessionsController < ApplicationController
else else
render_rejection :unauthorized render_rejection :unauthorized
end end
rescue ActiveRecord::RecordInvalid
render_rejection :unauthorized
end end
def destroy def destroy
@@ -18,6 +18,9 @@ class Users::ProfilesController < ApplicationController
else else
redirect_to user_profile_url, alert: update_alert redirect_to user_profile_url, alert: update_alert
end end
rescue ActiveRecord::RecordInvalid
# The password changed again before this browser's new session could start, and that change signed it out.
redirect_to new_session_url
end end
private private
+12 -3
View File
@@ -7,13 +7,22 @@ class Session < ApplicationRecord
before_create { self.last_active_at ||= Time.now } before_create { self.last_active_at ||= Time.now }
def self.start!(user_agent:, ip_address:) validate :user_password_unchanged, on: :create
create! user_agent: user_agent, ip_address: ip_address
end
def resume(user_agent:, ip_address:) def resume(user_agent:, ip_address:)
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago) if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
end end
end end
private
# A password change ends every session, so one can't start on a password checked before the change. Validated in
# the save's transaction, which holds the database's write lock from the start, so a change commits either before
# this check, and fails it, or after the insert, and ends this session with the others. Read past the query cache,
# and compared here rather than in the query, which logs its values.
def user_password_unchanged
unless User.uncached { User.where(id: user_id).pick(:password_digest) } == user.password_digest
errors.add :user, :invalid
end
end
end end
@@ -18,4 +18,53 @@ class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
assert_reject_connection { connect } assert_reject_connection { connect }
end end
test "closes once its session has ended without it hearing" do
cookies.signed[:session_token] = sessions(:david_safari).token
connect
sessions(:david_safari).destroy
travel 2.minutes
connection.beat
assert socket.closed
assert_equal "disconnect", socket.transmissions.last[:type]
assert socket.transmissions.last[:reconnect]
end
test "keeps checking its session while open" do
cookies.signed[:session_token] = sessions(:david_safari).token
connect
travel 2.minutes
connection.beat
assert_not socket.closed
sessions(:david_safari).destroy
travel 2.minutes
connection.beat
assert socket.closed
end
test "checks its session at most once a minute" do
cookies.signed[:session_token] = sessions(:david_safari).token
connect
sessions(:david_safari).destroy
travel 30.seconds
connection.beat
assert_not socket.closed
end
test "a rejected connection's heartbeat doesn't fail" do
request = ActionCable::Connection::TestSocket.build_request(ActionCable.server.config.mount_path || "/cable", cookies: cookies)
rejected = ApplicationCable::Connection.new \
ActionCable::Connection::TestServer.new(ActionCable.server), ActionCable::Connection::TestSocket.new(request)
rejected.handle_open
travel 2.minutes
assert_nothing_raised { rejected.beat }
end
end end
@@ -30,4 +30,19 @@ class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
assert_response :bad_request assert_response :bad_request
assert_nil parsed_cookies.signed[:session_token] assert_nil parsed_cookies.signed[:session_token]
end end
test "update is refused when the password changes after the link was checked" do
user = users(:david)
transfer_id = user.transfer_id
stale = User.find(user.id)
User.find(user.id).update!(password: "a new secret123456")
User.stubs(:find_by_transfer_id).with(transfer_id).returns(stale)
assert_no_difference -> { Session.count } do
put session_transfer_url(transfer_id)
end
assert_response :bad_request
assert_nil parsed_cookies.signed[:session_token]
end
end end
@@ -36,6 +36,19 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert parsed_cookies.signed[:session_token] assert parsed_cookies.signed[:session_token]
end end
test "create is refused when the password changes after it was checked" do
user = User.find(users(:david).id)
User.find(user.id).update!(password: "a new secret123456")
User.stubs(:authenticate_by).returns(user)
assert_no_difference -> { Session.count } do
post session_url, params: { email_address: "david@37signals.com", password: "secret123456" }
end
assert_response :unauthorized
assert_nil parsed_cookies.signed[:session_token]
end
test "create with invalid credentials" do test "create with invalid credentials" do
post session_url, params: { email_address: "david@37signals.com", password: "wrong" } post session_url, params: { email_address: "david@37signals.com", password: "wrong" }
@@ -43,6 +43,16 @@ class Users::ProfilesControllerTest < ActionDispatch::IntegrationTest
assert_equal 1, users(:david).sessions.count assert_equal 1, users(:david).sessions.count
end end
test "changing the password signs this browser out when it changes again before its new session starts" do
User.stubs(:uncached).returns(BCrypt::Password.create("someone else's secret", cost: BCrypt::Engine::MIN_COST))
assert_changes -> { users(:david).sessions.count }, to: 0 do
put user_profile_url, params: { user: { password: "a new secret123456", password_challenge: "secret123456" } }
end
assert_redirected_to new_session_url
end
test "changing the password gives this browser a new session, so a copy of its cookie stops working" do test "changing the password gives this browser a new session, so a copy of its cookie stops working" do
copied_cookie = cookies[:session_token] copied_cookie = cookies[:session_token]
+20
View File
@@ -0,0 +1,20 @@
require "test_helper"
class SessionTest < ActiveSupport::TestCase
test "start a session" do
assert_difference -> { users(:david).sessions.count }, +1 do
users(:david).sessions.create!(user_agent: "This device", ip_address: "127.0.0.1")
end
end
test "a session doesn't start on a password checked before it changed" do
user = User.find(users(:david).id)
User.find(user.id).update!(password: "a new secret123456")
assert_no_difference -> { Session.count } do
assert_raises(ActiveRecord::RecordInvalid) do
user.sessions.create!(user_agent: "This device", ip_address: "127.0.0.1")
end
end
end
end
+1 -1
View File
@@ -51,7 +51,7 @@ class UserTest < ActiveSupport::TestCase
end end
test "changing the password signs out every session, the current one included" do test "changing the password signs out every session, the current one included" do
Current.session = users(:david).sessions.start!(user_agent: "This device", ip_address: "127.0.0.1") Current.session = users(:david).sessions.create!(user_agent: "This device", ip_address: "127.0.0.1")
assert_changes -> { users(:david).sessions.count }, to: 0 do assert_changes -> { users(:david).sessions.count }, to: 0 do
users(:david).update!(password: "a new secret123456") users(:david).update!(password: "a new secret123456")