Skip to content

Commit c81ddfd

Browse files
author
Javi Manso
committed
Merge pull request rails#151 from rails-lts/secure-session-store
1 parent 81a0aea commit c81ddfd

File tree

6 files changed

+187
-26
lines changed

6 files changed

+187
-26
lines changed

lib/action_dispatch/session/active_record_store.rb

Lines changed: 29 additions & 12 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
@@ -67,11 +67,11 @@ class ActiveRecordStore < ActionDispatch::Session::AbstractStore
6767
private
6868
def get_session(request, sid)
6969
logger.silence do
70-
unless sid and session = @@session_class.find_by_session_id(sid)
70+
unless sid and session = get_session_with_fallback(sid)
7171
# If the sid was nil or if there is no pre-existing session under the sid,
7272
# force the generation of a new sid and associate a new session associated with the new sid
7373
sid = generate_sid
74-
session = @@session_class.new(:session_id => sid, :data => {})
74+
session = @@session_class.new(:session_id => sid.private_id, :data => {})
7575
end
7676
request.env[SESSION_RECORD_KEY] = session
7777
[sid, session.data]
@@ -80,7 +80,7 @@ def get_session(request, sid)
8080

8181
def write_session(request, sid, session_data, options)
8282
logger.silence do
83-
record = get_session_model(request, sid)
83+
record, sid = get_session_model(request, sid)
8484
record.data = session_data
8585
return false unless record.save
8686

@@ -98,7 +98,7 @@ def write_session(request, sid, session_data, options)
9898
def delete_session(request, session_id, options)
9999
logger.silence do
100100
if sid = current_session_id(request)
101-
if model = @@session_class.find_by_session_id(sid)
101+
if model = get_session_with_fallback(sid)
102102
data = model.data
103103
model.destroy
104104
end
@@ -110,7 +110,7 @@ def delete_session(request, session_id, options)
110110
new_sid = generate_sid
111111

112112
if options[:renew]
113-
new_model = @@session_class.new(:session_id => new_sid, :data => data)
113+
new_model = @@session_class.new(:session_id => new_sid.private_id, :data => data)
114114
new_model.save
115115
request.env[SESSION_RECORD_KEY] = new_model
116116
end
@@ -121,24 +121,35 @@ def delete_session(request, session_id, options)
121121

122122
def get_session_model(request, id)
123123
logger.silence do
124-
model = @@session_class.find_by_session_id(id)
125-
if !model
124+
model = get_session_with_fallback(id)
125+
unless model
126126
id = generate_sid
127-
model = @@session_class.new(:session_id => id, :data => {})
127+
model = @@session_class.new(:session_id => id.private_id, :data => {})
128128
model.save unless request.session_options[:skip]
129129
end
130130
if request.env[ENV_SESSION_OPTIONS_KEY][:id].nil?
131131
request.env[SESSION_RECORD_KEY] = model
132132
else
133133
request.env[SESSION_RECORD_KEY] ||= model
134134
end
135-
model
135+
[model, id]
136+
end
137+
end
138+
139+
def get_session_with_fallback(sid)
140+
if sid && !self.class.private_session_id?(sid.public_id)
141+
if (secure_session = @@session_class.find_by_session_id(sid.private_id))
142+
secure_session
143+
elsif (insecure_session = @@session_class.find_by_session_id(sid.public_id))
144+
insecure_session.session_id = sid.private_id # this causes the session to be secured
145+
insecure_session
146+
end
136147
end
137148
end
138149

139150
def find_session(request, id)
140-
model = get_session_model(request, id)
141-
[model.session_id, model.data]
151+
model, id = get_session_model(request, id)
152+
[id, model.data]
142153
end
143154

144155
module NilLogger
@@ -150,6 +161,12 @@ def self.silence
150161
def logger
151162
ActiveRecord::Base.logger || NilLogger
152163
end
164+
165+
def self.private_session_id?(session_id)
166+
# user tried to retrieve a session by a private key?
167+
session_id =~ /\A\d+::/
168+
end
169+
153170
end
154171
end
155172
end

lib/active_record/session_store/session.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ def setup_sessid_compatibility!
4545
# Reset column info since it may be stale.
4646
reset_column_information
4747
if columns_hash['sessid']
48-
def self.find_by_session_id(*args)
49-
find_by_sessid(*args)
48+
def self.find_by_session_id(session_id)
49+
find_by_sessid(session_id)
5050
end
5151

5252
define_method(:session_id) { sessid }
@@ -78,6 +78,27 @@ def loaded?
7878
@data
7979
end
8080

81+
# This method was introduced when addressing CVE-2019-16782
82+
# (see https://github.com/rack/rack/security/advisories/GHSA-hrqr-hxpp-chr3).
83+
# Sessions created on version <= 1.1.3 were guessable via a timing attack.
84+
# To secure sessions created on those old versions, this method can be called
85+
# on all existing sessions in the database. Users will not lose their session
86+
# when this is done.
87+
def secure!
88+
session_id_column = if self.class.columns_hash['sessid']
89+
:sessid
90+
else
91+
:session_id
92+
end
93+
raw_session_id = read_attribute(session_id_column)
94+
if ActionDispatch::Session::ActiveRecordStore.private_session_id?(raw_session_id)
95+
# is already private, nothing to do
96+
else
97+
session_id_object = Rack::Session::SessionId.new(raw_session_id)
98+
update_column(session_id_column, session_id_object.private_id)
99+
end
100+
end
101+
81102
private
82103
def serialize_data!
83104
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: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ def get_session_value
2323

2424
def get_session_id
2525
if ActiveRecord::VERSION::MAJOR == 4
26-
render :text => "#{request.session.id}"
26+
render :text => "#{request.session['session_id']}"
2727
else
28-
render :plain => "#{request.session.id}"
28+
render :plain => "#{request.session['session_id']}"
2929
end
3030
end
3131

@@ -285,4 +285,65 @@ def test_session_store_with_all_domains
285285
assert_response :success
286286
end
287287
end
288+
289+
%w{ session sql_bypass }.each do |class_name|
290+
define_method :"test_sessions_are_indexed_by_a_hashed_session_id_for_#{class_name}" do
291+
with_store(class_name) do
292+
with_test_route_set do
293+
get '/set_session_value'
294+
assert_response :success
295+
public_session_id = cookies['_session_id']
296+
297+
session = ActiveRecord::SessionStore::Session.last
298+
assert session
299+
assert_not_equal public_session_id, session.session_id
300+
301+
expected_private_id = Rack::Session::SessionId.new(public_session_id).private_id
302+
303+
assert_equal expected_private_id, session.session_id
304+
end
305+
end
306+
end
307+
308+
define_method :"test_unsecured_sessions_are_retrieved_and_migrated_for_#{class_name}" do
309+
with_store(class_name) do
310+
with_test_route_set do
311+
get '/set_session_value', params: { foo: 'baz' }
312+
assert_response :success
313+
public_session_id = cookies['_session_id']
314+
315+
session = ActiveRecord::SessionStore::Session.last
316+
session.data # otherwise we cannot save
317+
session.session_id = public_session_id
318+
session.save!
319+
320+
get '/get_session_value'
321+
assert_response :success
322+
assert_equal 'foo: "baz"', response.body
323+
324+
session = ActiveRecord::SessionStore::Session.last
325+
assert_not_equal public_session_id, session.read_attribute(:session_id)
326+
end
327+
end
328+
end
329+
330+
# to avoid a different kind of timing attack
331+
define_method :"test_sessions_cannot_be_retrieved_by_their_private_session_id_for_#{class_name}" do
332+
with_store(class_name) do
333+
with_test_route_set do
334+
get '/set_session_value', params: { foo: 'baz' }
335+
assert_response :success
336+
337+
session = ActiveRecord::SessionStore::Session.last
338+
private_session_id = session.read_attribute(:session_id)
339+
340+
cookies.merge("_session_id=#{private_session_id};path=/")
341+
342+
get '/get_session_value'
343+
assert_response :success
344+
assert_equal 'foo: nil', response.body
345+
end
346+
end
347+
end
348+
end
288349
end

test/destroy_session_test.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def test_destroy_without_renew
2222
s['set_something_so_it_loads'] = true
2323

2424
session_model = req.env[record_key]
25-
session_model.update_attributes(:data => {'rails' => 'ftw'})
25+
session_model.update(:data => {'rails' => 'ftw'})
2626

2727
s.destroy
2828

@@ -35,7 +35,7 @@ def test_destroy_with_renew
3535
s['set_something_so_it_loads'] = true
3636

3737
session_model = req.env[record_key]
38-
session_model.update_attributes(:data => {'rails' => 'ftw'})
38+
session_model.update(:data => {'rails' => 'ftw'})
3939

4040
s.destroy
4141

@@ -50,4 +50,4 @@ def store
5050
end
5151
end
5252
end
53-
end
53+
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)