Commit 7e7cf0b8 authored by Alex Kalderimis's avatar Alex Kalderimis

Use Gitlab::Json to serialize sessions

This changes session serialization, shifting from the use
of Marshal (which has security implications) to the safer use of JSON.

In order to support existing active sessions in the old format, we
lookup sessions by both the old and new keys, and fallback to the old
parsing if the stored value is not JSON.

Changelog: security
parent b990ab0b
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
# #
# The raw session information is stored by the Rails session store # The raw session information is stored by the Rails session store
# (config/initializers/session_store.rb). These entries are accessible by the # (config/initializers/session_store.rb). These entries are accessible by the
# rack_key_name class method and consistute the base of the session data # rack_key_name class method and constitute the base of the session data
# entries. All other entries in the session store can be traced back to these # entries. All other entries in the session store can be traced back to these
# entries. # entries.
# #
...@@ -26,10 +26,19 @@ class ActiveSession ...@@ -26,10 +26,19 @@ class ActiveSession
SESSION_BATCH_SIZE = 200 SESSION_BATCH_SIZE = 200
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100 ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
attr_accessor :created_at, :updated_at, attr_accessor :ip_address, :browser, :os,
:ip_address, :browser, :os, :device_name, :device_type,
:device_name, :device_type, :is_impersonated, :session_id, :session_private_id
:is_impersonated, :session_id, :session_private_id
attr_reader :created_at, :updated_at
def created_at=(time)
@created_at = time.is_a?(String) ? Time.zone.parse(time) : time
end
def updated_at=(time)
@updated_at = time.is_a?(String) ? Time.zone.parse(time) : time
end
def current?(rack_session) def current?(rack_session)
return false if session_private_id.nil? || rack_session.id.nil? return false if session_private_id.nil? || rack_session.id.nil?
...@@ -39,6 +48,15 @@ class ActiveSession ...@@ -39,6 +48,15 @@ class ActiveSession
session_private_id == rack_session.id.private_id session_private_id == rack_session.id.private_id
end end
def eql?(other)
other.is_a?(self.class) && id == other.id
end
alias_method :==, :eql?
def id
session_private_id.presence || session_id
end
def human_device_type def human_device_type
device_type&.titleize device_type&.titleize
end end
...@@ -65,7 +83,7 @@ class ActiveSession ...@@ -65,7 +83,7 @@ class ActiveSession
redis.setex( redis.setex(
key_name(user.id, session_private_id), key_name(user.id, session_private_id),
Settings.gitlab['session_expire_delay'] * 60, Settings.gitlab['session_expire_delay'] * 60,
Marshal.dump(active_user_session) active_user_session.dump
) )
redis.sadd( redis.sadd(
...@@ -92,7 +110,10 @@ class ActiveSession ...@@ -92,7 +110,10 @@ class ActiveSession
end end
def self.destroy_sessions(redis, user, session_ids) def self.destroy_sessions(redis, user, session_ids)
return if session_ids.empty?
key_names = session_ids.map { |session_id| key_name(user.id, session_id) } key_names = session_ids.map { |session_id| key_name(user.id, session_id) }
key_names += session_ids.map { |session_id| key_name_v1(user.id, session_id) }
redis.srem(lookup_key_name(user.id), session_ids) redis.srem(lookup_key_name(user.id), session_ids)
...@@ -115,7 +136,7 @@ class ActiveSession ...@@ -115,7 +136,7 @@ class ActiveSession
sessions.reject! { |session| session.current?(current_rack_session) } if current_rack_session sessions.reject! { |session| session.current?(current_rack_session) } if current_rack_session
redis_store_class.with do |redis| redis_store_class.with do |redis|
session_ids = (sessions.map(&:session_id) | sessions.map(&:session_private_id)).compact session_ids = sessions.map(&:id).compact
destroy_sessions(redis, user, session_ids) if session_ids.any? destroy_sessions(redis, user, session_ids) if session_ids.any?
end end
end end
...@@ -129,6 +150,11 @@ class ActiveSession ...@@ -129,6 +150,11 @@ class ActiveSession
end end
def self.key_name(user_id, session_id = '*') def self.key_name(user_id, session_id = '*')
"#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}:v2:#{user_id}:#{session_id}"
end
# Deprecated
def self.key_name_v1(user_id, session_id = '*')
"#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}" "#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}"
end end
...@@ -170,41 +196,57 @@ class ActiveSession ...@@ -170,41 +196,57 @@ class ActiveSession
end end
end end
# Deserializes a session Hash object from Redis. def dump
# "v1:#{Gitlab::Json.dump(self)}"
end
# raw_session - Raw bytes from Redis # raw_session - Raw bytes from Redis
# #
# Returns an ActiveSession object # Returns an instance of this class
def self.load_raw_session(raw_session) def self.load_raw_session(raw_session)
# rubocop:disable Security/MarshalLoad return unless raw_session
# Explanation of why this Marshal.load call is OK:
# https://gitlab.com/gitlab-com/gl-security/appsec/appsec-reviews/-/issues/124#note_744576714 if raw_session.start_with?('v1:')
Marshal.load(raw_session) session_data = Gitlab::Json.parse(raw_session[3..]).symbolize_keys
# rubocop:enable Security/MarshalLoad new(**session_data)
else
# Deprecated legacy format. To be removed in 15.0
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/30516
# Explanation of why this Marshal.load call is OK:
# https://gitlab.com/gitlab-com/gl-security/appsec/appsec-reviews/-/issues/124#note_744576714
# rubocop:disable Security/MarshalLoad
Marshal.load(raw_session)
# rubocop:enable Security/MarshalLoad
end
end end
def self.rack_session_keys(rack_session_ids) def self.rack_session_keys(rack_session_ids)
rack_session_ids.map { |session_id| rack_key_name(session_id)} rack_session_ids.map { |session_id| rack_key_name(session_id) }
end end
def self.raw_active_session_entries(redis, session_ids, user_id) def self.raw_active_session_entries(redis, session_ids, user_id)
return [] if session_ids.empty? return {} if session_ids.empty?
entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } found = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
session_ids.zip(redis.mget(entry_keys)).to_h
end
Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do fallbacks = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
redis.mget(entry_keys) entry_keys = session_ids.map { |session_id| key_name_v1(user_id, session_id) }
session_ids.zip(redis.mget(entry_keys)).to_h
end end
fallbacks.merge(found.compact)
end end
def self.active_session_entries(session_ids, user_id, redis) def self.active_session_entries(session_ids, user_id, redis)
return [] if session_ids.empty? return [] if session_ids.empty?
entry_keys = raw_active_session_entries(redis, session_ids, user_id) raw_active_session_entries(redis, session_ids, user_id)
.values
entry_keys.compact.map do |raw_session| .compact
load_raw_session(raw_session) .map { load_raw_session(_1) }
end
end end
def self.clean_up_old_sessions(redis, user) def self.clean_up_old_sessions(redis, user)
...@@ -212,12 +254,15 @@ class ActiveSession ...@@ -212,12 +254,15 @@ class ActiveSession
return if session_ids.count <= ALLOWED_NUMBER_OF_ACTIVE_SESSIONS return if session_ids.count <= ALLOWED_NUMBER_OF_ACTIVE_SESSIONS
# remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS.
sessions = active_session_entries(session_ids, user.id, redis) sessions = active_session_entries(session_ids, user.id, redis)
sessions.sort_by! {|session| session.updated_at }.reverse! sessions.sort_by!(&:updated_at).reverse!
destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
destroyable_session_ids = destroyable_sessions.flat_map { |session| [session.session_id, session.session_private_id] }.compact # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS.
destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any? destroyable_session_ids = sessions
.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
.flat_map { |session| [session.session_id, session.session_private_id].compact }
destroy_sessions(redis, user, destroyable_session_ids)
end end
# Cleans up the lookup set by removing any session IDs that are no longer present. # Cleans up the lookup set by removing any session IDs that are no longer present.
...@@ -225,18 +270,17 @@ class ActiveSession ...@@ -225,18 +270,17 @@ class ActiveSession
# Returns an array of marshalled ActiveModel objects that are still active. # Returns an array of marshalled ActiveModel objects that are still active.
def self.cleaned_up_lookup_entries(redis, user) def self.cleaned_up_lookup_entries(redis, user)
session_ids = session_ids_for_user(user.id) session_ids = session_ids_for_user(user.id)
entries = raw_active_session_entries(redis, session_ids, user.id) session_ids_and_entries = raw_active_session_entries(redis, session_ids, user.id)
# remove expired keys. # remove expired keys.
# only the single key entries are automatically expired by redis, the # only the single key entries are automatically expired by redis, the
# lookup entries in the set need to be removed manually. # lookup entries in the set need to be removed manually.
session_ids_and_entries = session_ids.zip(entries)
redis.pipelined do |pipeline| redis.pipelined do |pipeline|
session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry| session_ids_and_entries.each do |session_id, entry|
pipeline.srem(lookup_key_name(user.id), session_id) pipeline.srem(lookup_key_name(user.id), session_id) unless entry
end end
end end
entries.compact session_ids_and_entries.values.compact
end end
end end
...@@ -127,8 +127,7 @@ namespace :gitlab do ...@@ -127,8 +127,7 @@ namespace :gitlab do
lookup_key_count = redis.scard(key) lookup_key_count = redis.scard(key)
session_ids = ActiveSession.session_ids_for_user(user_id) session_ids = ActiveSession.session_ids_for_user(user_id)
entries = ActiveSession.raw_active_session_entries(redis, session_ids, user_id) session_ids_and_entries = ActiveSession.raw_active_session_entries(redis, session_ids, user_id)
session_ids_and_entries = session_ids.zip(entries)
inactive_session_ids = session_ids_and_entries.map do |session_id, session| inactive_session_ids = session_ids_and_entries.map do |session_id, session|
session_id if session.nil? session_id if session.nil?
......
This diff is collapsed.
...@@ -174,9 +174,9 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do ...@@ -174,9 +174,9 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do
before do before do
Gitlab::Redis::Sessions.with do |redis| Gitlab::Redis::Sessions.with do |redis|
redis.set("session:user:gitlab:#{user.id}:#{existing_session_id}", redis.set(ActiveSession.key_name(user.id, existing_session_id),
Marshal.dump(true)) Gitlab::Json.dump(ActiveSession.new(session_id: 'x')))
redis.sadd("session:lookup:user:gitlab:#{user.id}", (1..10).to_a) redis.sadd(ActiveSession.lookup_key_name(user.id), (1..10).to_a)
end end
end end
...@@ -186,10 +186,10 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do ...@@ -186,10 +186,10 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do
it 'removes expired active session lookup keys' do it 'removes expired active session lookup keys' do
Gitlab::Redis::Sessions.with do |redis| Gitlab::Redis::Sessions.with do |redis|
lookup_key = "session:lookup:user:gitlab:#{user.id}" lookup_key = ActiveSession.lookup_key_name(user.id)
expect { subject }.to change { redis.scard(lookup_key) }.from(10).to(1) expect { subject }.to change { redis.scard(lookup_key) }.from(10).to(1)
expect(redis.smembers("session:lookup:user:gitlab:#{user.id}")).to( expect(redis.smembers(lookup_key)).to contain_exactly existing_session_id
eql([existing_session_id]))
end end
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment