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 @@
#
# The raw session information is stored by the Rails session store
# (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.
#
......@@ -26,10 +26,19 @@ class ActiveSession
SESSION_BATCH_SIZE = 200
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
attr_accessor :created_at, :updated_at,
:ip_address, :browser, :os,
:device_name, :device_type,
:is_impersonated, :session_id, :session_private_id
attr_accessor :ip_address, :browser, :os,
:device_name, :device_type,
: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)
return false if session_private_id.nil? || rack_session.id.nil?
......@@ -39,6 +48,15 @@ class ActiveSession
session_private_id == rack_session.id.private_id
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
device_type&.titleize
end
......@@ -65,7 +83,7 @@ class ActiveSession
redis.setex(
key_name(user.id, session_private_id),
Settings.gitlab['session_expire_delay'] * 60,
Marshal.dump(active_user_session)
active_user_session.dump
)
redis.sadd(
......@@ -92,7 +110,10 @@ class ActiveSession
end
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_v1(user.id, session_id) }
redis.srem(lookup_key_name(user.id), session_ids)
......@@ -115,7 +136,7 @@ class ActiveSession
sessions.reject! { |session| session.current?(current_rack_session) } if current_rack_session
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?
end
end
......@@ -129,6 +150,11 @@ class ActiveSession
end
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}"
end
......@@ -170,41 +196,57 @@ class ActiveSession
end
end
# Deserializes a session Hash object from Redis.
#
def dump
"v1:#{Gitlab::Json.dump(self)}"
end
# raw_session - Raw bytes from Redis
#
# Returns an ActiveSession object
# Returns an instance of this class
def self.load_raw_session(raw_session)
# rubocop:disable Security/MarshalLoad
# Explanation of why this Marshal.load call is OK:
# https://gitlab.com/gitlab-com/gl-security/appsec/appsec-reviews/-/issues/124#note_744576714
Marshal.load(raw_session)
# rubocop:enable Security/MarshalLoad
return unless raw_session
if raw_session.start_with?('v1:')
session_data = Gitlab::Json.parse(raw_session[3..]).symbolize_keys
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
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
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
redis.mget(entry_keys)
fallbacks = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
entry_keys = session_ids.map { |session_id| key_name_v1(user_id, session_id) }
session_ids.zip(redis.mget(entry_keys)).to_h
end
fallbacks.merge(found.compact)
end
def self.active_session_entries(session_ids, user_id, redis)
return [] if session_ids.empty?
entry_keys = raw_active_session_entries(redis, session_ids, user_id)
entry_keys.compact.map do |raw_session|
load_raw_session(raw_session)
end
raw_active_session_entries(redis, session_ids, user_id)
.values
.compact
.map { load_raw_session(_1) }
end
def self.clean_up_old_sessions(redis, user)
......@@ -212,12 +254,15 @@ class ActiveSession
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.sort_by! {|session| session.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
destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any?
sessions.sort_by!(&:updated_at).reverse!
# remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS.
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
# Cleans up the lookup set by removing any session IDs that are no longer present.
......@@ -225,18 +270,17 @@ class ActiveSession
# Returns an array of marshalled ActiveModel objects that are still active.
def self.cleaned_up_lookup_entries(redis, user)
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.
# only the single key entries are automatically expired by redis, the
# lookup entries in the set need to be removed manually.
session_ids_and_entries = session_ids.zip(entries)
redis.pipelined do |pipeline|
session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry|
pipeline.srem(lookup_key_name(user.id), session_id)
session_ids_and_entries.each do |session_id, entry|
pipeline.srem(lookup_key_name(user.id), session_id) unless entry
end
end
entries.compact
session_ids_and_entries.values.compact
end
end
......@@ -127,8 +127,7 @@ namespace :gitlab do
lookup_key_count = redis.scard(key)
session_ids = ActiveSession.session_ids_for_user(user_id)
entries = ActiveSession.raw_active_session_entries(redis, session_ids, user_id)
session_ids_and_entries = session_ids.zip(entries)
session_ids_and_entries = ActiveSession.raw_active_session_entries(redis, session_ids, user_id)
inactive_session_ids = session_ids_and_entries.map do |session_id, session|
session_id if session.nil?
......
This diff is collapsed.
......@@ -174,9 +174,9 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do
before do
Gitlab::Redis::Sessions.with do |redis|
redis.set("session:user:gitlab:#{user.id}:#{existing_session_id}",
Marshal.dump(true))
redis.sadd("session:lookup:user:gitlab:#{user.id}", (1..10).to_a)
redis.set(ActiveSession.key_name(user.id, existing_session_id),
Gitlab::Json.dump(ActiveSession.new(session_id: 'x')))
redis.sadd(ActiveSession.lookup_key_name(user.id), (1..10).to_a)
end
end
......@@ -186,10 +186,10 @@ RSpec.describe 'gitlab:cleanup rake tasks', :silence_stdout do
it 'removes expired active session lookup keys' do
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(redis.smembers("session:lookup:user:gitlab:#{user.id}")).to(
eql([existing_session_id]))
expect(redis.smembers(lookup_key)).to contain_exactly existing_session_id
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