Skip to content

Commit 9d4dd11

Browse files
authored
Merge pull request #151 from rails-lts/secure-session-store
Address CVE-2019-16782
2 parents f188efb + 532a9a5 commit 9d4dd11

File tree

6 files changed

+187
-27
lines changed

6 files changed

+187
-27
lines changed

activerecord-session_store.gemspec

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ Gem::Specification.new do |s|
1919
s.extra_rdoc_files = %w( README.md )
2020
s.rdoc_options.concat ['--main', 'README.md']
2121

22-
s.add_dependency('activerecord', '>= 5.2')
23-
s.add_dependency('actionpack', '>= 5.2')
24-
s.add_dependency('railties', '>= 5.2')
25-
s.add_dependency('rack', '>= 2.0.0', '< 3')
22+
s.add_dependency('activerecord', '>= 5.2.4.1')
23+
s.add_dependency('actionpack', '>= 5.2.4.1')
24+
s.add_dependency('railties', '>= 5.2.4.1')
25+
s.add_dependency('rack', '>= 2.0.8', '< 3')
2626
s.add_dependency('multi_json', '~> 1.11', '>= 1.11.2')
2727

2828
s.add_development_dependency('sqlite3')

lib/action_dispatch/session/active_record_store.rb

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ module Session
5252
#
5353
# The example SqlBypass class is a generic SQL session store. You may
5454
# use it as a basis for high-performance database-specific stores.
55-
class ActiveRecordStore < ActionDispatch::Session::AbstractStore
55+
class ActiveRecordStore < ActionDispatch::Session::AbstractSecureStore
5656
# The class used for session storage. Defaults to
5757
# ActiveRecord::SessionStore::Session
5858
cattr_accessor :session_class
@@ -63,11 +63,11 @@ class ActiveRecordStore < ActionDispatch::Session::AbstractStore
6363
private
6464
def get_session(request, sid)
6565
logger.silence do
66-
unless sid and session = @@session_class.find_by_session_id(sid)
66+
unless sid and session = get_session_with_fallback(sid)
6767
# If the sid was nil or if there is no pre-existing session under the sid,
6868
# force the generation of a new sid and associate a new session associated with the new sid
6969
sid = generate_sid
70-
session = @@session_class.new(:session_id => sid, :data => {})
70+
session = @@session_class.new(:session_id => sid.private_id, :data => {})
7171
end
7272
request.env[SESSION_RECORD_KEY] = session
7373
[sid, session.data]
@@ -76,7 +76,7 @@ def get_session(request, sid)
7676

7777
def write_session(request, sid, session_data, options)
7878
logger.silence do
79-
record = get_session_model(request, sid)
79+
record, sid = get_session_model(request, sid)
8080
record.data = session_data
8181
return false unless record.save
8282

@@ -94,7 +94,7 @@ def write_session(request, sid, session_data, options)
9494
def delete_session(request, session_id, options)
9595
logger.silence do
9696
if sid = current_session_id(request)
97-
if model = @@session_class.find_by_session_id(sid)
97+
if model = get_session_with_fallback(sid)
9898
data = model.data
9999
model.destroy
100100
end
@@ -106,7 +106,7 @@ def delete_session(request, session_id, options)
106106
new_sid = generate_sid
107107

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

118118
def get_session_model(request, id)
119119
logger.silence do
120-
model = @@session_class.find_by_session_id(id)
121-
if !model
120+
model = get_session_with_fallback(id)
121+
unless model
122122
id = generate_sid
123-
model = @@session_class.new(:session_id => id, :data => {})
123+
model = @@session_class.new(:session_id => id.private_id, :data => {})
124124
model.save
125125
end
126126
if request.env[ENV_SESSION_OPTIONS_KEY][:id].nil?
127127
request.env[SESSION_RECORD_KEY] = model
128128
else
129129
request.env[SESSION_RECORD_KEY] ||= model
130130
end
131-
model
131+
[model, id]
132+
end
133+
end
134+
135+
def get_session_with_fallback(sid)
136+
if sid && !self.class.private_session_id?(sid.public_id)
137+
if (secure_session = @@session_class.find_by_session_id(sid.private_id))
138+
secure_session
139+
elsif (insecure_session = @@session_class.find_by_session_id(sid.public_id))
140+
insecure_session.session_id = sid.private_id # this causes the session to be secured
141+
insecure_session
142+
end
132143
end
133144
end
134145

135146
def find_session(request, id)
136-
model = get_session_model(request, id)
137-
[model.session_id, model.data]
147+
model, id = get_session_model(request, id)
148+
[id, model.data]
138149
end
139150

140151
module NilLogger
@@ -146,7 +157,12 @@ def self.silence
146157
def logger
147158
ActiveRecord::Base.logger || NilLogger
148159
end
160+
161+
def self.private_session_id?(session_id)
162+
# user tried to retrieve a session by a private key?
163+
session_id =~ /\A\d+::/
164+
end
165+
149166
end
150167
end
151168
end
152-

lib/active_record/session_store/session.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ def setup_sessid_compatibility!
3838
# Reset column info since it may be stale.
3939
reset_column_information
4040
if columns_hash['sessid']
41-
def self.find_by_session_id(*args)
42-
find_by_sessid(*args)
41+
def self.find_by_session_id(session_id)
42+
find_by_sessid(session_id)
4343
end
4444

4545
define_method(:session_id) { sessid }
@@ -71,6 +71,27 @@ def loaded?
7171
@data
7272
end
7373

74+
# This method was introduced when addressing CVE-2019-16782
75+
# (see https://github.com/rack/rack/security/advisories/GHSA-hrqr-hxpp-chr3).
76+
# Sessions created on version <= 1.1.3 were guessable via a timing attack.
77+
# To secure sessions created on those old versions, this method can be called
78+
# on all existing sessions in the database. Users will not lose their session
79+
# when this is done.
80+
def secure!
81+
session_id_column = if self.class.columns_hash['sessid']
82+
:sessid
83+
else
84+
:session_id
85+
end
86+
raw_session_id = read_attribute(session_id_column)
87+
if ActionDispatch::Session::ActiveRecordStore.private_session_id?(raw_session_id)
88+
# is already private, nothing to do
89+
else
90+
session_id_object = Rack::Session::SessionId.new(raw_session_id)
91+
update_column(session_id_column, session_id_object.private_id)
92+
end
93+
end
94+
7495
private
7596
def serialize_data!
7697
unless loaded?

lib/active_record/session_store/sql_bypass.rb

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,16 @@ def connection_pool
6060

6161
# Look up a session by id and deserialize its data if found.
6262
def find_by_session_id(session_id)
63-
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)}")
64-
new(:session_id => session_id, :serialized_data => record['data'])
63+
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)}")
64+
new(:session_id => session_id, :retrieved_by => session_id, :serialized_data => record['data'])
6565
end
6666
end
6767
end
6868

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

71-
attr_reader :session_id, :new_record
71+
attr_reader :new_record
72+
attr_accessor :session_id
7273
alias :new_record? :new_record
7374

7475
attr_writer :data
@@ -77,7 +78,8 @@ def find_by_session_id(session_id)
7778
# telling us to postpone deserializing until the data is requested.
7879
# We need to handle a normal data attribute in case of a new record.
7980
def initialize(attributes)
80-
@session_id = attributes[:session_id]
81+
@session_id = attributes[:session_id]
82+
@retrieved_by = attributes[:retrieved_by]
8183
@data = attributes[:data]
8284
@serialized_data = attributes[:serialized_data]
8385
@new_record = @serialized_data.nil?
@@ -122,8 +124,10 @@ def save
122124
else
123125
connect.update <<-end_sql, 'Update session'
124126
UPDATE #{table_name}
125-
SET #{connect.quote_column_name(data_column)}=#{connect.quote(serialized_data)}
126-
WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id)}
127+
SET
128+
#{connect.quote_column_name(data_column)}=#{connect.quote(serialized_data)},
129+
#{connect.quote_column_name(session_id_column)}=#{connect.quote(@session_id)}
130+
WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(@retrieved_by)}
127131
end_sql
128132
end
129133
end
@@ -134,7 +138,7 @@ def destroy
134138
connect = connection
135139
connect.delete <<-end_sql, 'Destroy session'
136140
DELETE FROM #{table_name}
137-
WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id.to_s)}
141+
WHERE #{connect.quote_column_name(session_id_column)}=#{connect.quote(session_id)}
138142
end_sql
139143
end
140144
end

test/action_controller_test.rb

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def get_session_value
1818
end
1919

2020
def get_session_id
21-
render :plain => "#{request.session.id}"
21+
render :plain => "#{request.session['session_id']}"
2222
end
2323

2424
def call_reset_session
@@ -257,4 +257,65 @@ def test_session_store_with_all_domains
257257
assert_response :success
258258
end
259259
end
260+
261+
%w{ session sql_bypass }.each do |class_name|
262+
define_method :"test_sessions_are_indexed_by_a_hashed_session_id_for_#{class_name}" do
263+
with_store(class_name) do
264+
with_test_route_set do
265+
get '/set_session_value'
266+
assert_response :success
267+
public_session_id = cookies['_session_id']
268+
269+
session = ActiveRecord::SessionStore::Session.last
270+
assert session
271+
assert_not_equal public_session_id, session.session_id
272+
273+
expected_private_id = Rack::Session::SessionId.new(public_session_id).private_id
274+
275+
assert_equal expected_private_id, session.session_id
276+
end
277+
end
278+
end
279+
280+
define_method :"test_unsecured_sessions_are_retrieved_and_migrated_for_#{class_name}" do
281+
with_store(class_name) do
282+
with_test_route_set do
283+
get '/set_session_value', params: { foo: 'baz' }
284+
assert_response :success
285+
public_session_id = cookies['_session_id']
286+
287+
session = ActiveRecord::SessionStore::Session.last
288+
session.data # otherwise we cannot save
289+
session.session_id = public_session_id
290+
session.save!
291+
292+
get '/get_session_value'
293+
assert_response :success
294+
assert_equal 'foo: "baz"', response.body
295+
296+
session = ActiveRecord::SessionStore::Session.last
297+
assert_not_equal public_session_id, session.read_attribute(:session_id)
298+
end
299+
end
300+
end
301+
302+
# to avoid a different kind of timing attack
303+
define_method :"test_sessions_cannot_be_retrieved_by_their_private_session_id_for_#{class_name}" do
304+
with_store(class_name) do
305+
with_test_route_set do
306+
get '/set_session_value', params: { foo: 'baz' }
307+
assert_response :success
308+
309+
session = ActiveRecord::SessionStore::Session.last
310+
private_session_id = session.read_attribute(:session_id)
311+
312+
cookies.merge("_session_id=#{private_session_id};path=/")
313+
314+
get '/get_session_value'
315+
assert_response :success
316+
assert_equal 'foo: nil', response.body
317+
end
318+
end
319+
end
320+
end
260321
end

test/session_test.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,64 @@ def test_loaded?
123123
s = Session.new
124124
assert !s.loaded?, 'session is not loaded'
125125
end
126+
127+
def test_session_can_be_secured
128+
Session.create_table!
129+
session_id = 'unsecure'
130+
session = session_klass.create!(:data => 'world', :session_id => 'foo')
131+
session.update_column(:session_id, session_id)
132+
133+
assert_equal 'unsecure', session.read_attribute(:session_id)
134+
135+
session.secure!
136+
137+
secured = Rack::Session::SessionId.new(session_id).private_id
138+
assert_equal secured, session.reload.read_attribute(:session_id)
139+
end
140+
141+
def test_session_can_be_secured_with_sessid_compatibility
142+
# Force class reload, as we need to redo the meta-programming
143+
ActiveRecord::SessionStore.send(:remove_const, :Session)
144+
load 'active_record/session_store/session.rb'
145+
146+
Session.reset_column_information
147+
klass = Class.new(Session) do
148+
def self.session_id_column
149+
'sessid'
150+
end
151+
end
152+
klass.create_table!
153+
session_id = 'unsecure'
154+
session = klass.create!(:data => 'world', :sessid => 'foo')
155+
session.update_column(:sessid, session_id)
156+
157+
assert_equal 'unsecure', session.read_attribute(:sessid)
158+
159+
session.secure!
160+
161+
secured = Rack::Session::SessionId.new(session_id).private_id
162+
assert_equal secured, session.reload.read_attribute(:sessid)
163+
ensure
164+
klass.drop_table!
165+
Session.reset_column_information
166+
end
167+
168+
def test_secure_is_idempotent
169+
Session.create_table!
170+
session_id = 'unsecure'
171+
session = session_klass.create!(:data => 'world', :session_id => 'foo')
172+
session.update_column(:session_id, session_id)
173+
174+
assert_equal 'unsecure', session.read_attribute(:session_id)
175+
176+
session.secure!
177+
private_id = session.read_attribute(:session_id)
178+
session.secure!
179+
session.reload
180+
session.secure!
181+
182+
assert_equal private_id, session.reload.read_attribute(:session_id)
183+
end
126184
end
127185
end
128186
end

0 commit comments

Comments
 (0)