Fix possible information leak / session hijacking vulnerability.

The `ActionDispatch::Session::MemcacheStore` is still vulnerable
given it requires the gem dalli to be updated as well.

CVE-2019-16782
This commit is contained in:
Rafael Mendonça França 2019-12-17 18:44:59 -03:00
parent fe3db2a14e
commit 92ec9f270d
No known key found for this signature in database
GPG Key ID: FC23B6D0F1EEE948
11 changed files with 180 additions and 28 deletions

View File

@ -39,7 +39,7 @@ PATH
actionpack (6.1.0.alpha) actionpack (6.1.0.alpha)
actionview (= 6.1.0.alpha) actionview (= 6.1.0.alpha)
activesupport (= 6.1.0.alpha) activesupport (= 6.1.0.alpha)
rack (~> 2.0) rack (~> 2.0, >= 2.0.8)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
@ -367,7 +367,7 @@ GEM
thor thor
raabro (1.1.6) raabro (1.1.6)
racc (1.4.15) racc (1.4.15)
rack (2.0.7) rack (2.0.8)
rack-cache (1.9.0) rack-cache (1.9.0)
rack (>= 0.4) rack (>= 0.4)
rack-protection (2.0.7) rack-protection (2.0.7)

View File

@ -1,3 +1,10 @@
* Fix possible information leak / session hijacking vulnerability.
The `ActionDispatch::Session::MemcacheStore` is still vulnerable given it requires the
gem dalli to be updated as well.
CVE-2019-16782.
* Include child session assertion count in ActionDispatch::IntegrationTest * Include child session assertion count in ActionDispatch::IntegrationTest
`IntegrationTest#open_session` uses `dup` to create the new session, which `IntegrationTest#open_session` uses `dup` to create the new session, which

View File

@ -34,7 +34,7 @@ Gem::Specification.new do |s|
s.add_dependency "activesupport", version s.add_dependency "activesupport", version
s.add_dependency "rack", "~> 2.0" s.add_dependency "rack", "~> 2.0", ">= 2.0.8"
s.add_dependency "rack-test", ">= 0.6.3" s.add_dependency "rack-test", ">= 0.6.3"
s.add_dependency "rails-html-sanitizer", "~> 1.0", ">= 1.2.0" s.add_dependency "rails-html-sanitizer", "~> 1.0", ">= 1.2.0"
s.add_dependency "rails-dom-testing", "~> 2.0" s.add_dependency "rails-dom-testing", "~> 2.0"

View File

@ -89,10 +89,11 @@ module ActionDispatch
end end
module Session module Session
autoload :AbstractStore, "action_dispatch/middleware/session/abstract_store" autoload :AbstractStore, "action_dispatch/middleware/session/abstract_store"
autoload :CookieStore, "action_dispatch/middleware/session/cookie_store" autoload :AbstractSecureStore, "action_dispatch/middleware/session/abstract_store"
autoload :MemCacheStore, "action_dispatch/middleware/session/mem_cache_store" autoload :CookieStore, "action_dispatch/middleware/session/cookie_store"
autoload :CacheStore, "action_dispatch/middleware/session/cache_store" autoload :MemCacheStore, "action_dispatch/middleware/session/mem_cache_store"
autoload :CacheStore, "action_dispatch/middleware/session/cache_store"
end end
mattr_accessor :test_app mattr_accessor :test_app

View File

@ -86,5 +86,20 @@ module ActionDispatch
request.cookie_jar[key] = cookie request.cookie_jar[key] = cookie
end end
end end
class AbstractSecureStore < Rack::Session::Abstract::PersistedSecure
include Compatibility
include StaleSessionCheck
include SessionObject
def generate_sid
Rack::Session::SessionId.new(super)
end
private
def set_cookie(request, session_id, cookie)
request.cookie_jar[key] = cookie
end
end
end end
end end

View File

@ -12,7 +12,7 @@ module ActionDispatch
# * <tt>cache</tt> - The cache to use. If it is not specified, <tt>Rails.cache</tt> will be used. # * <tt>cache</tt> - The cache to use. If it is not specified, <tt>Rails.cache</tt> will be used.
# * <tt>expire_after</tt> - The length of time a session will be stored before automatically expiring. # * <tt>expire_after</tt> - The length of time a session will be stored before automatically expiring.
# By default, the <tt>:expires_in</tt> option of the cache is used. # By default, the <tt>:expires_in</tt> option of the cache is used.
class CacheStore < AbstractStore class CacheStore < AbstractSecureStore
def initialize(app, options = {}) def initialize(app, options = {})
@cache = options[:cache] || Rails.cache @cache = options[:cache] || Rails.cache
options[:expire_after] ||= @cache.options[:expires_in] options[:expire_after] ||= @cache.options[:expires_in]
@ -21,7 +21,7 @@ module ActionDispatch
# Get a session from the cache. # Get a session from the cache.
def find_session(env, sid) def find_session(env, sid)
unless sid && (session = @cache.read(cache_key(sid))) unless sid && (session = get_session_with_fallback(sid))
sid, session = generate_sid, {} sid, session = generate_sid, {}
end end
[sid, session] [sid, session]
@ -29,7 +29,7 @@ module ActionDispatch
# Set a session in the cache. # Set a session in the cache.
def write_session(env, sid, session, options) def write_session(env, sid, session, options)
key = cache_key(sid) key = cache_key(sid.private_id)
if session if session
@cache.write(key, session, expires_in: options[:expire_after]) @cache.write(key, session, expires_in: options[:expire_after])
else else
@ -40,14 +40,19 @@ module ActionDispatch
# Remove a session from the cache. # Remove a session from the cache.
def delete_session(env, sid, options) def delete_session(env, sid, options)
@cache.delete(cache_key(sid)) @cache.delete(cache_key(sid.private_id))
@cache.delete(cache_key(sid.public_id))
generate_sid generate_sid
end end
private private
# Turn the session id into a cache key. # Turn the session id into a cache key.
def cache_key(sid) def cache_key(id)
"_session_id:#{sid}" "_session_id:#{id}"
end
def get_session_with_fallback(sid)
@cache.read(cache_key(sid.private_id)) || @cache.read(cache_key(sid.public_id))
end end
end end
end end

View File

@ -46,7 +46,16 @@ module ActionDispatch
# would set the session cookie to expire automatically 14 days after creation. # would set the session cookie to expire automatically 14 days after creation.
# Other useful options include <tt>:key</tt>, <tt>:secure</tt> and # Other useful options include <tt>:key</tt>, <tt>:secure</tt> and
# <tt>:httponly</tt>. # <tt>:httponly</tt>.
class CookieStore < AbstractStore class CookieStore < AbstractSecureStore
class SessionId < DelegateClass(Rack::Session::SessionId)
attr_reader :cookie_value
def initialize(session_id, cookie_value = {})
super(session_id)
@cookie_value = cookie_value
end
end
def initialize(app, options = {}) def initialize(app, options = {})
super(app, options.merge!(cookie_only: true)) super(app, options.merge!(cookie_only: true))
end end
@ -54,7 +63,7 @@ module ActionDispatch
def delete_session(req, session_id, options) def delete_session(req, session_id, options)
new_sid = generate_sid unless options[:drop] new_sid = generate_sid unless options[:drop]
# Reset hash and Assign the new session id # Reset hash and Assign the new session id
req.set_header("action_dispatch.request.unsigned_session_cookie", new_sid ? { "session_id" => new_sid } : {}) req.set_header("action_dispatch.request.unsigned_session_cookie", new_sid ? { "session_id" => new_sid.public_id } : {})
new_sid new_sid
end end
@ -62,14 +71,15 @@ module ActionDispatch
stale_session_check! do stale_session_check! do
data = unpacked_cookie_data(req) data = unpacked_cookie_data(req)
data = persistent_session_id!(data) data = persistent_session_id!(data)
[data["session_id"], data] [Rack::Session::SessionId.new(data["session_id"]), data]
end end
end end
private private
def extract_session_id(req) def extract_session_id(req)
stale_session_check! do stale_session_check! do
unpacked_cookie_data(req)["session_id"] sid = unpacked_cookie_data(req)["session_id"]
sid && Rack::Session::SessionId.new(sid)
end end
end end
@ -87,13 +97,13 @@ module ActionDispatch
def persistent_session_id!(data, sid = nil) def persistent_session_id!(data, sid = nil)
data ||= {} data ||= {}
data["session_id"] ||= sid || generate_sid data["session_id"] ||= sid || generate_sid.public_id
data data
end end
def write_session(req, sid, session_data, options) def write_session(req, sid, session_data, options)
session_data["session_id"] = sid session_data["session_id"] = sid.public_id
session_data SessionId.new(sid, session_data)
end end
def set_cookie(request, session_id, cookie) def set_cookie(request, session_id, cookie)

View File

@ -90,7 +90,13 @@ module ActionDispatch
# +nil+ if the given key is not found in the session. # +nil+ if the given key is not found in the session.
def [](key) def [](key)
load_for_read! load_for_read!
@delegate[key.to_s] key = key.to_s
if key == "session_id"
id&.public_id
else
@delegate[key]
end
end end
# Returns the nested value specified by the sequence of keys, returning # Returns the nested value specified by the sequence of keys, returning

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
require "abstract_unit"
require "action_dispatch/middleware/session/abstract_store"
module ActionDispatch
module Session
class AbstractSecureStoreTest < ActiveSupport::TestCase
class MemoryStore < AbstractSecureStore
class SessionId < Rack::Session::SessionId
attr_reader :cookie_value
def initialize(session_id, cookie_value)
super(session_id)
@cookie_value = cookie_value
end
end
def initialize(app)
@sessions = {}
super
end
def find_session(env, sid)
sid ||= 1
session = @sessions[sid] ||= {}
[sid, session]
end
def write_session(env, sid, session, options)
@sessions[sid] = SessionId.new(sid, session)
end
end
def test_session_is_set
env = {}
as = MemoryStore.new app
as.call(env)
assert @env
assert Request::Session.find ActionDispatch::Request.new @env
end
def test_new_session_object_is_merged_with_old
env = {}
as = MemoryStore.new app
as.call(env)
assert @env
session = Request::Session.find ActionDispatch::Request.new @env
session["foo"] = "bar"
as.call(@env)
session1 = Request::Session.find ActionDispatch::Request.new @env
assert_not_equal session, session1
assert_equal session.to_hash, session1.to_hash
end
private
def app(&block)
@env = nil
lambda { |env| @env = env }
end
end
end
end

View File

@ -24,7 +24,7 @@ class CacheStoreTest < ActionDispatch::IntegrationTest
end end
def get_session_id def get_session_id
render plain: "#{request.session.id}" render plain: "#{request.session.id.public_id}"
end end
def call_reset_session def call_reset_session
@ -150,15 +150,56 @@ class CacheStoreTest < ActionDispatch::IntegrationTest
def test_prevents_session_fixation def test_prevents_session_fixation
with_test_route_set do with_test_route_set do
assert_nil @cache.read("_session_id:0xhax") sid = Rack::Session::SessionId.new("0xhax")
assert_nil @cache.read("_session_id:#{sid.private_id}")
cookies["_session_id"] = "0xhax" cookies["_session_id"] = sid.public_id
get "/set_session_value" get "/set_session_value"
assert_response :success assert_response :success
assert_not_equal "0xhax", cookies["_session_id"] assert_not_equal sid.public_id, cookies["_session_id"]
assert_nil @cache.read("_session_id:0xhax") assert_nil @cache.read("_session_id:#{sid.private_id}")
assert_equal({ "foo" => "bar" }, @cache.read("_session_id:#{cookies['_session_id']}")) assert_equal(
{ "foo" => "bar" },
@cache.read("_session_id:#{Rack::Session::SessionId.new(cookies['_session_id']).private_id}")
)
end
end
def test_can_read_session_with_legacy_id
with_test_route_set do
get "/set_session_value"
assert_response :success
assert cookies["_session_id"]
sid = Rack::Session::SessionId.new(cookies['_session_id'])
session = @cache.read("_session_id:#{sid.private_id}")
@cache.delete("_session_id:#{sid.private_id}")
@cache.write("_session_id:#{sid.public_id}", session)
get "/get_session_value"
assert_response :success
assert_equal 'foo: "bar"', response.body
end
end
def test_drop_session_in_the_legacy_id_as_well
with_test_route_set do
get "/set_session_value"
assert_response :success
assert cookies["_session_id"]
sid = Rack::Session::SessionId.new(cookies['_session_id'])
session = @cache.read("_session_id:#{sid.private_id}")
@cache.delete("_session_id:#{sid.private_id}")
@cache.write("_session_id:#{sid.public_id}", session)
get "/call_reset_session"
assert_response :success
assert_not_equal [], headers["Set-Cookie"]
assert_nil @cache.read("_session_id:#{sid.private_id}")
assert_nil @cache.read("_session_id:#{sid.public_id}")
end end
end end

View File

@ -36,7 +36,7 @@ class CookieStoreTest < ActionDispatch::IntegrationTest
end end
def get_session_id def get_session_id
render plain: "id: #{request.session.id}" render plain: "id: #{request.session.id&.public_id}"
end end
def get_class_after_reset_session def get_class_after_reset_session