Skip to content

Commit 981b37e

Browse files
committed
Harden file-backed session storage
Reject unsafe permissions and symbolic links to prevent session data from being exposed through shared storage.
1 parent 5e4ba37 commit 981b37e

3 files changed

Lines changed: 124 additions & 5 deletions

File tree

lib/cgi/session.rb

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,20 @@ class CGI
149149
# session.close
150150
#
151151
class Session
152+
# :stopdoc:
153+
common_mode = File::BINARY
154+
common_mode |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
155+
READ_MODE = File::RDONLY|File::SHARE_DELETE|common_mode
156+
LOCK_MODE = File::CREAT|File::RDWR|common_mode
157+
NEW_MODE = File::CREAT|File::EXCL|File::WRONLY|common_mode
158+
# :startdoc:
152159

153160
class NoSession < RuntimeError #:nodoc:
154161
end
155162

163+
class UnsafeSessionFileError < RuntimeError #:nodoc:
164+
end
165+
156166
# The id of this session.
157167
attr_reader :session_id, :new_session
158168

@@ -230,15 +240,31 @@ def new_store_file(option={}) # :nodoc:
230240
path << digest
231241
path << suffix if suffix
232242
if File::exist? path
243+
self.class.open_store_file(path).close
233244
hash = nil
234245
elsif new_session
246+
File.open(path, NEW_MODE, 0600) {}
235247
hash = {}
236248
else
237249
raise NoSession, "uninitialized session"
238250
end
239251
return path, hash
240252
end
241253

254+
# Windows do not obey the POSIX permission model.
255+
MODE_MASK = /mswin|mingw|bccwin|wince/ =~ RUBY_PLATFORM ? 0 : 0o077 # :nodoc:
256+
private_constant :MODE_MASK
257+
258+
def self.open_store_file(path, mode = READ_MODE)
259+
f = File.open(path, mode)
260+
stat = f.stat
261+
unless stat.owned? and (stat.mode & MODE_MASK).zero?
262+
f.close
263+
raise UnsafeSessionFileError, "not owned session file"
264+
end
265+
f
266+
end
267+
242268
# Create a new CGI::Session object for +request+.
243269
#
244270
# +request+ is an instance of the +CGI+ class (see cgi.rb).
@@ -437,9 +463,10 @@ def restore
437463
unless @hash
438464
@hash = {}
439465
begin
440-
lockf = File.open(@path+".lock", "r")
466+
lockf = File.open(@path+".lock", READ_MODE)
441467
lockf.flock File::LOCK_SH
442-
f = File.open(@path, 'r')
468+
raise UnsafeSessionFileError, "not owned lock file" unless lockf.stat.owned?
469+
f = CGI::Session.open_store_file(@path)
443470
for line in f
444471
line.chomp!
445472
k, v = line.split('=',2)
@@ -457,9 +484,9 @@ def restore
457484
def update
458485
return unless @hash
459486
begin
460-
lockf = File.open(@path+".lock", File::CREAT|File::RDWR, 0600)
487+
lockf = File.open(@path+".lock", LOCK_MODE, 0600)
461488
lockf.flock File::LOCK_EX
462-
f = File.open(@path+".new", File::CREAT|File::TRUNC|File::WRONLY, 0600)
489+
f = File.open(@path+".new", NEW_MODE, 0600)
463490
for k,v in @hash
464491
f.printf "%s=%s\n", CGI.escape(k), CGI.escape(String(Marshal.dump(v)))
465492
end

lib/cgi/session/pstore.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ class Session
2424
# library file pstore.rb. Session data is marshalled and stored
2525
# in a file. File locking and transaction services are provided.
2626
class PStore
27+
PSTORE_OPT = {} # :nodoc:
28+
if ::PStore.instance_method(:initialize)
29+
.parameters
30+
.include?([:key, :follow_symlink])
31+
PSTORE_OPT[:follow_symlink] = false
32+
end
33+
PSTORE_OPT.freeze
34+
2735
# Create a new CGI::Session::PStore instance
2836
#
2937
# This constructor is used internally by CGI::Session. The
@@ -49,7 +57,13 @@ class PStore
4957
def initialize(session, option={})
5058
option = {'suffix'=>''}.update(option)
5159
path, @hash = session.new_store_file(option)
52-
@p = ::PStore.new(path)
60+
61+
# In Ruby 2.6 or earlier, **{} is passed as a positional Hash.
62+
@p = if PSTORE_OPT.empty?
63+
::PStore.new(path)
64+
else
65+
::PStore.new(path, **PSTORE_OPT)
66+
end
5367
@p.transaction do |p|
5468
File.chmod(0600, p.path)
5569
end

test/cgi/test_cgi_session.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,86 @@ def test_cgi_session_filestore_path
187187
assert_equal path_sha512, path
188188
end
189189

190+
def test_rejects_insecure_session_file
191+
session_id = "insecure"
192+
path = session_file_store_path("tmpdir"=>@session_dir,
193+
"session_id"=>session_id)
194+
File.write(path, "")
195+
File.chmod(0644, path)
196+
197+
assert_raise(CGI::Session::UnsafeSessionFileError) do
198+
CGI::Session.new(Object.new, "tmpdir"=>@session_dir,
199+
"session_id"=>session_id)
200+
end
201+
end
202+
203+
def test_filestore_update_rejects_existing_new_file
204+
session = CGI::Session.new(Object.new, "tmpdir"=>@session_dir,
205+
"session_id"=>"stale")
206+
path = session.instance_variable_get(:@dbman).instance_variable_get(:@path)
207+
new_path = path + ".new"
208+
File.write(new_path, "existing")
209+
session["key"] = "value"
210+
211+
assert_raise(Errno::EEXIST) do
212+
session.close
213+
end
214+
assert_equal("existing", File.read(new_path))
215+
ensure
216+
session.delete if session
217+
end
218+
219+
def test_pstore_does_not_enable_thread_safety_for_compatibility
220+
session = CGI::Session.new(Object.new, "tmpdir"=>@session_dir,
221+
"session_id"=>"pstore-compat",
222+
"database_manager"=>CGI::Session::PStore)
223+
pstore = session.instance_variable_get(:@dbman).instance_variable_get(:@p)
224+
225+
assert_equal(false, pstore.instance_variable_get(:@thread_safe))
226+
ensure
227+
session.delete if session
228+
end if defined?(::PStore)
229+
230+
def test_pstore_rejects_session_file_replaced_by_symlink
231+
omit("O_NOFOLLOW is not supported") unless nofollow_supported?
232+
233+
session = CGI::Session.new(Object.new, "tmpdir"=>@session_dir,
234+
"session_id"=>"pstore-symlink",
235+
"database_manager"=>CGI::Session::PStore)
236+
session["key"] = "secret"
237+
pstore = session.instance_variable_get(:@dbman).instance_variable_get(:@p)
238+
target = File.join(@session_dir, "target")
239+
File.write(target, "")
240+
File.unlink(pstore.path)
241+
File.symlink("target", pstore.path)
242+
243+
assert_raise(Errno::ELOOP) do
244+
session.close
245+
end
246+
assert_empty(File.read(target))
247+
ensure
248+
session.delete if session
249+
end if defined?(::PStore) and !CGI::Session::PStore::PSTORE_OPT.empty?
250+
190251
private
191252

253+
def nofollow_supported?
254+
return false unless File.const_defined?(:NOFOLLOW)
255+
256+
target = File.join(@session_dir, "nofollow-target")
257+
link = File.join(@session_dir, "nofollow-link")
258+
File.write(target, "")
259+
File.symlink(target, link)
260+
File.open(link, File::RDONLY|File::NOFOLLOW).close
261+
false
262+
rescue Errno::ELOOP
263+
true
264+
rescue NotImplementedError, SystemCallError
265+
false
266+
ensure
267+
[target, link].each {|file| File.unlink(file) if file}
268+
end
269+
192270
def assert_session_filestore_path(path, dir: @session_dir, prefix: "cgi_sid_", suffix: nil)
193271
base = File.basename(path)
194272
assert_equal dir, File.dirname(path)

0 commit comments

Comments
 (0)