Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Address CVE-2019-16782 #151

Merged
merged 4 commits into from
Mar 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions activerecord-session_store.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ Gem::Specification.new do |s|
s.extra_rdoc_files = %w( README.md )
s.rdoc_options.concat ['--main', 'README.md']

s.add_dependency('activerecord', '>= 5.2')
s.add_dependency('actionpack', '>= 5.2')
s.add_dependency('railties', '>= 5.2')
s.add_dependency('rack', '>= 2.0.0', '< 3')
s.add_dependency('activerecord', '>= 5.2.4.1')
s.add_dependency('actionpack', '>= 5.2.4.1')
s.add_dependency('railties', '>= 5.2.4.1')
s.add_dependency('rack', '>= 2.0.8', '< 3')
s.add_dependency('multi_json', '~> 1.11', '>= 1.11.2')

s.add_development_dependency('sqlite3')
Expand Down
42 changes: 29 additions & 13 deletions lib/action_dispatch/session/active_record_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ module Session
#
# The example SqlBypass class is a generic SQL session store. You may
# use it as a basis for high-performance database-specific stores.
class ActiveRecordStore < ActionDispatch::Session::AbstractStore
class ActiveRecordStore < ActionDispatch::Session::AbstractSecureStore
# The class used for session storage. Defaults to
# ActiveRecord::SessionStore::Session
cattr_accessor :session_class
Expand All @@ -63,11 +63,11 @@ class ActiveRecordStore < ActionDispatch::Session::AbstractStore
private
def get_session(request, sid)
logger.silence do
unless sid and session = @@session_class.find_by_session_id(sid)
unless sid and session = get_session_with_fallback(sid)
# If the sid was nil or if there is no pre-existing session under the sid,
# force the generation of a new sid and associate a new session associated with the new sid
sid = generate_sid
session = @@session_class.new(:session_id => sid, :data => {})
session = @@session_class.new(:session_id => sid.private_id, :data => {})
end
request.env[SESSION_RECORD_KEY] = session
[sid, session.data]
Expand All @@ -76,7 +76,7 @@ def get_session(request, sid)

def write_session(request, sid, session_data, options)
logger.silence do
record = get_session_model(request, sid)
record, sid = get_session_model(request, sid)
record.data = session_data
return false unless record.save

Expand All @@ -94,7 +94,7 @@ def write_session(request, sid, session_data, options)
def delete_session(request, session_id, options)
logger.silence do
if sid = current_session_id(request)
if model = @@session_class.find_by_session_id(sid)
if model = get_session_with_fallback(sid)
data = model.data
model.destroy
end
Expand All @@ -106,7 +106,7 @@ def delete_session(request, session_id, options)
new_sid = generate_sid

if options[:renew]
new_model = @@session_class.new(:session_id => new_sid, :data => data)
new_model = @@session_class.new(:session_id => new_sid.private_id, :data => data)
new_model.save
request.env[SESSION_RECORD_KEY] = new_model
end
Expand All @@ -117,24 +117,35 @@ def delete_session(request, session_id, options)

def get_session_model(request, id)
logger.silence do
model = @@session_class.find_by_session_id(id)
if !model
model = get_session_with_fallback(id)
unless model
id = generate_sid
model = @@session_class.new(:session_id => id, :data => {})
model = @@session_class.new(:session_id => id.private_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
[model, id]
end
end

def get_session_with_fallback(sid)
if sid && !self.class.private_session_id?(sid.public_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this check? How the user will get access to a private id?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't add this protection, the fallback mechanism allows an attacker to guess the private id with a timing attack similar to the original vulnerability.

Copy link
Contributor

@thorsteneckel thorsteneckel Mar 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imagine public_id just being a private_id in disguise. The attacker would send an public_key which is in fact a private_id! Line 136 will fail because public_id (aka "evil private_id") will be a double hashed private_id. This will cause line 139 to be executed. Which is basically the same insecure behaviour is as it is right now.
This line prevents this. If public_id is in fact a private_id it won't get used at all and no lookup will be performed.
Instead a new and secure key/session will be generated and used ✅

if (secure_session = @@session_class.find_by_session_id(sid.private_id))
secure_session
elsif (insecure_session = @@session_class.find_by_session_id(sid.public_id))
insecure_session.session_id = sid.private_id # this causes the session to be secured
insecure_session
end
end
end

def find_session(request, id)
model = get_session_model(request, id)
[model.session_id, model.data]
model, id = get_session_model(request, id)
[id, model.data]
end

module NilLogger
Expand All @@ -146,7 +157,12 @@ def self.silence
def logger
ActiveRecord::Base.logger || NilLogger
end

def self.private_session_id?(session_id)
# user tried to retrieve a session by a private key?
session_id =~ /\A\d+::/
end

end
end
end

25 changes: 23 additions & 2 deletions lib/active_record/session_store/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ def setup_sessid_compatibility!
# Reset column info since it may be stale.
reset_column_information
if columns_hash['sessid']
def self.find_by_session_id(*args)
find_by_sessid(*args)
def self.find_by_session_id(session_id)
find_by_sessid(session_id)
end

define_method(:session_id) { sessid }
Expand Down Expand Up @@ -71,6 +71,27 @@ def loaded?
@data
end

# This method was introduced when addressing CVE-2019-16782
# (see https://github.com/rack/rack/security/advisories/GHSA-hrqr-hxpp-chr3).
# Sessions created on version <= 1.1.3 were guessable via a timing attack.
# To secure sessions created on those old versions, this method can be called
# on all existing sessions in the database. Users will not lose their session
# when this is done.
def secure!
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this method was added?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this should probably be documented.

Normally, sessions are only upgraded to secure sessions, when a user makes a request using the insecure session. This allows existing user sessions to be upgraded to secure sessions while the users are offline.

Copy link
Contributor

@thorsteneckel thorsteneckel Mar 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will allow maintainers to create a migration which basically performs a ActionDispatch::Session::ActiveRecordStore.session_class.find_each(&:secure!) to migrate all existing insecure sessions to secure ones. The next lookup with the private_id will find the secured value in the DB and just work as for new secure sessions. The fallback case won't be executed and no additional DB query will be performed (faster/lower load).

An remark in the README.md would be great though 💡

session_id_column = if self.class.columns_hash['sessid']
:sessid
else
:session_id
end
raw_session_id = read_attribute(session_id_column)
if ActionDispatch::Session::ActiveRecordStore.private_session_id?(raw_session_id)
# is already private, nothing to do
else
session_id_object = Rack::Session::SessionId.new(raw_session_id)
update_column(session_id_column, session_id_object.private_id)
end
end

private
def serialize_data!
unless loaded?
Expand Down
18 changes: 11 additions & 7 deletions lib/active_record/session_store/sql_bypass.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,16 @@ def connection_pool

# Look up a session by id and deserialize its data if found.
def find_by_session_id(session_id)
if record = connection.select_one("SELECT #{connection.quote_column_name(data_column)} AS data FROM #{@@table_name} WHERE #{connection.quote_column_name(@@session_id_column)}=#{connection.quote(session_id.to_s)}")
new(:session_id => session_id, :serialized_data => record['data'])
if record = connection.select_one("SELECT #{connection.quote_column_name(data_column)} AS data FROM #{@@table_name} WHERE #{connection.quote_column_name(@@session_id_column)}=#{connection.quote(session_id)}")
new(:session_id => session_id, :retrieved_by => session_id, :serialized_data => record['data'])
end
end
end

delegate :connection, :connection=, :connection_pool, :connection_pool=, :to => self

attr_reader :session_id, :new_record
attr_reader :new_record
attr_accessor :session_id
alias :new_record? :new_record

attr_writer :data
Expand All @@ -77,7 +78,8 @@ def find_by_session_id(session_id)
# telling us to postpone deserializing until the data is requested.
# We need to handle a normal data attribute in case of a new record.
def initialize(attributes)
@session_id = attributes[:session_id]
@session_id = attributes[:session_id]
@retrieved_by = attributes[:retrieved_by]
@data = attributes[:data]
@serialized_data = attributes[:serialized_data]
@new_record = @serialized_data.nil?
Expand Down Expand Up @@ -122,8 +124,10 @@ def save
else
connect.update <<-end_sql, 'Update session'
UPDATE #{table_name}
SET #{connect.quote_column_name(data_column)}=#{connect.quote(serialized_data)}
WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id)}
SET
#{connect.quote_column_name(data_column)}=#{connect.quote(serialized_data)},
#{connect.quote_column_name(session_id_column)}=#{connect.quote(@session_id)}
WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(@retrieved_by)}
end_sql
end
end
Expand All @@ -134,7 +138,7 @@ def destroy
connect = connection
connect.delete <<-end_sql, 'Destroy session'
DELETE FROM #{table_name}
WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id.to_s)}
WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id)}
end_sql
end
end
Expand Down
63 changes: 62 additions & 1 deletion test/action_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def get_session_value
end

def get_session_id
render :plain => "#{request.session.id}"
render :plain => "#{request.session['session_id']}"
end

def call_reset_session
Expand Down Expand Up @@ -257,4 +257,65 @@ def test_session_store_with_all_domains
assert_response :success
end
end

%w{ session sql_bypass }.each do |class_name|
define_method :"test_sessions_are_indexed_by_a_hashed_session_id_for_#{class_name}" do
with_store(class_name) do
with_test_route_set do
get '/set_session_value'
assert_response :success
public_session_id = cookies['_session_id']

session = ActiveRecord::SessionStore::Session.last
assert session
assert_not_equal public_session_id, session.session_id

expected_private_id = Rack::Session::SessionId.new(public_session_id).private_id

assert_equal expected_private_id, session.session_id
end
end
end

define_method :"test_unsecured_sessions_are_retrieved_and_migrated_for_#{class_name}" do
with_store(class_name) do
with_test_route_set do
get '/set_session_value', params: { foo: 'baz' }
assert_response :success
public_session_id = cookies['_session_id']

session = ActiveRecord::SessionStore::Session.last
session.data # otherwise we cannot save
session.session_id = public_session_id
session.save!

get '/get_session_value'
assert_response :success
assert_equal 'foo: "baz"', response.body

session = ActiveRecord::SessionStore::Session.last
assert_not_equal public_session_id, session.read_attribute(:session_id)
end
end
end

# to avoid a different kind of timing attack
define_method :"test_sessions_cannot_be_retrieved_by_their_private_session_id_for_#{class_name}" do
with_store(class_name) do
with_test_route_set do
get '/set_session_value', params: { foo: 'baz' }
assert_response :success

session = ActiveRecord::SessionStore::Session.last
private_session_id = session.read_attribute(:session_id)

cookies.merge("_session_id=#{private_session_id};path=/")

get '/get_session_value'
assert_response :success
assert_equal 'foo: nil', response.body
end
end
end
end
end
58 changes: 58 additions & 0 deletions test/session_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,64 @@ def test_loaded?
s = Session.new
assert !s.loaded?, 'session is not loaded'
end

def test_session_can_be_secured
Session.create_table!
session_id = 'unsecure'
session = session_klass.create!(:data => 'world', :session_id => 'foo')
session.update_column(:session_id, session_id)

assert_equal 'unsecure', session.read_attribute(:session_id)

session.secure!

secured = Rack::Session::SessionId.new(session_id).private_id
assert_equal secured, session.reload.read_attribute(:session_id)
end

def test_session_can_be_secured_with_sessid_compatibility
# Force class reload, as we need to redo the meta-programming
ActiveRecord::SessionStore.send(:remove_const, :Session)
load 'active_record/session_store/session.rb'

Session.reset_column_information
klass = Class.new(Session) do
def self.session_id_column
'sessid'
end
end
klass.create_table!
session_id = 'unsecure'
session = klass.create!(:data => 'world', :sessid => 'foo')
session.update_column(:sessid, session_id)

assert_equal 'unsecure', session.read_attribute(:sessid)

session.secure!

secured = Rack::Session::SessionId.new(session_id).private_id
assert_equal secured, session.reload.read_attribute(:sessid)
ensure
klass.drop_table!
Session.reset_column_information
end

def test_secure_is_idempotent
Session.create_table!
session_id = 'unsecure'
session = session_klass.create!(:data => 'world', :session_id => 'foo')
session.update_column(:session_id, session_id)

assert_equal 'unsecure', session.read_attribute(:session_id)

session.secure!
private_id = session.read_attribute(:session_id)
session.secure!
session.reload
session.secure!

assert_equal private_id, session.reload.read_attribute(:session_id)
end
end
end
end