Commit 722b0f3f authored by Sebastian Arcila Valenzuela's avatar Sebastian Arcila Valenzuela Committed by Yorick Peterse

Validate that SAML requests are originated from gitlab

If the request wasn't initiated by gitlab we shouldn't add the new
identity to the user, and instead show that we weren't able to link
the identity to the user.

This should fix: https://gitlab.com/gitlab-org/gitlab-ce/issues/56509

And reuse SAML logic within EE SAMLGroup, this is to avoid duplication
between the code used for CE and EE.
parent 69b089c6
...@@ -40,6 +40,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -40,6 +40,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def saml def saml
omniauth_flow(Gitlab::Auth::Saml) omniauth_flow(Gitlab::Auth::Saml)
rescue Gitlab::Auth::Saml::IdentityLinker::UnverifiedRequest
redirect_unverified_saml_initiation
end end
def omniauth_error def omniauth_error
...@@ -92,8 +94,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -92,8 +94,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
return render_403 unless link_provider_allowed?(oauth['provider']) return render_403 unless link_provider_allowed?(oauth['provider'])
log_audit_event(current_user, with: oauth['provider']) log_audit_event(current_user, with: oauth['provider'])
identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session)
identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth)
link_identity(identity_linker) link_identity(identity_linker)
...@@ -194,6 +195,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -194,6 +195,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
redirect_to new_user_session_path redirect_to new_user_session_path
end end
def redirect_unverified_saml_initiation
redirect_to profile_account_path, notice: _('Request to link SAML account must be authorized')
end
def handle_disabled_provider def handle_disabled_provider
label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider']) label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider'])
flash[:alert] = _("Signing in using %{label} has been disabled") % { label: label } flash[:alert] = _("Signing in using %{label} has been disabled") % { label: label }
......
---
title: Prevent GitLab accounts takeover if SAML is configured
merge_request:
author:
type: security
...@@ -9,10 +9,10 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController ...@@ -9,10 +9,10 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController
@unauthenticated_group = Group.find_by_full_path(params[:group_id]) @unauthenticated_group = Group.find_by_full_path(params[:group_id])
@saml_provider = @unauthenticated_group.saml_provider @saml_provider = @unauthenticated_group.saml_provider
identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(current_user, oauth, @saml_provider, session) identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(current_user, oauth, session, @saml_provider)
omniauth_flow(Gitlab::Auth::GroupSaml, identity_linker: identity_linker) omniauth_flow(Gitlab::Auth::GroupSaml, identity_linker: identity_linker)
rescue Gitlab::Auth::GroupSaml::IdentityLinker::UnverifiedRequest rescue Gitlab::Auth::Saml::IdentityLinker::UnverifiedRequest
redirect_unverified_saml_initiation redirect_unverified_saml_initiation
end end
......
...@@ -15,7 +15,7 @@ module GroupSaml ...@@ -15,7 +15,7 @@ module GroupSaml
new_user.managing_group = group if group.saml_provider&.enforced_group_managed_accounts? new_user.managing_group = group if group.saml_provider&.enforced_group_managed_accounts?
if new_user.save if new_user.save
identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(new_user, oauth_data, group.saml_provider, session) identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(new_user, oauth_data, session, group.saml_provider)
identity_linker.link identity_linker.link
end end
......
...@@ -4,20 +4,16 @@ module Gitlab ...@@ -4,20 +4,16 @@ module Gitlab
module Auth module Auth
module GroupSaml module GroupSaml
class IdentityLinker < Gitlab::Auth::Saml::IdentityLinker class IdentityLinker < Gitlab::Auth::Saml::IdentityLinker
attr_reader :saml_provider, :session attr_reader :saml_provider
UnverifiedRequest = Class.new(StandardError) def initialize(current_user, oauth, session, saml_provider)
super(current_user, oauth, session)
def initialize(current_user, oauth, saml_provider, session)
super(current_user, oauth)
@saml_provider = saml_provider @saml_provider = saml_provider
@session = session
end end
override :link
def link def link
raise_unless_request_is_gitlab_initiated! if unlinked?
super super
update_group_membership unless failed? update_group_membership unless failed?
...@@ -37,18 +33,6 @@ module Gitlab ...@@ -37,18 +33,6 @@ module Gitlab
def update_group_membership def update_group_membership
MembershipUpdater.new(current_user, saml_provider).execute MembershipUpdater.new(current_user, saml_provider).execute
end end
def raise_unless_request_is_gitlab_initiated!
raise UnverifiedRequest unless valid_gitlab_initated_request?
end
def valid_gitlab_initated_request?
SamlOriginValidator.new(session).gitlab_initiated?(saml_response)
end
def saml_response
oauth.extra.response_object
end
end end
end end
end end
......
# frozen_string_literal: true
module Gitlab
module Auth
class SamlOriginValidator
attr_reader :session
AUTH_REQUEST_SESSION_KEY = "last_authn_request_id".freeze
def initialize(session)
@session = session
end
def store_origin(authn_request)
session[AUTH_REQUEST_SESSION_KEY] = authn_request.uuid
end
def gitlab_initiated?(saml_response)
return false if identity_provider_initiated?(saml_response)
matches?(saml_response)
end
private
def matches?(saml_response)
saml_response.in_response_to == expected_request_id
end
def identity_provider_initiated?(saml_response)
saml_response.in_response_to.blank?
end
def expected_request_id
session[AUTH_REQUEST_SESSION_KEY]
end
end
end
end
...@@ -40,20 +40,6 @@ module OmniAuth ...@@ -40,20 +40,6 @@ module OmniAuth
end end
end end
# NOTE: This method duplicates code from omniauth-saml
# so that we can access authn_request to store it
# See: https://github.com/omniauth/omniauth-saml/issues/172
override :request_phase
def request_phase
authn_request = OneLogin::RubySaml::Authrequest.new
store_authn_request_id(authn_request)
with_settings do |settings|
redirect(authn_request.create(settings, additional_params_for_authn_request))
end
end
def emulate_relay_state def emulate_relay_state
request.query_string.sub!('redirect_to', 'RelayState') request.query_string.sub!('redirect_to', 'RelayState')
end end
...@@ -85,10 +71,6 @@ module OmniAuth ...@@ -85,10 +71,6 @@ module OmniAuth
on_subpath?(:metadata) on_subpath?(:metadata)
end end
def store_authn_request_id(authn_request)
Gitlab::Auth::SamlOriginValidator.new(session).store_origin(authn_request)
end
def group_lookup def group_lookup
@group_lookup ||= Gitlab::Auth::GroupSaml::GroupLookup.new(env) @group_lookup ||= Gitlab::Auth::GroupSaml::GroupLookup.new(env)
end end
......
...@@ -20,8 +20,8 @@ describe 'Profile > Account' do ...@@ -20,8 +20,8 @@ describe 'Profile > Account' do
def create_linked_identity def create_linked_identity
oauth = { 'provider' => 'group_saml', 'uid' => '1' } oauth = { 'provider' => 'group_saml', 'uid' => '1' }
identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(user, oauth, saml_provider, double(:session)) identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(user, oauth, double(:session), saml_provider)
allow(identity_linker).to receive(:valid_gitlab_initated_request?).and_return(true) allow(identity_linker).to receive(:valid_gitlab_initiated_request?).and_return(true)
identity_linker.link identity_linker.link
end end
......
...@@ -12,7 +12,7 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do ...@@ -12,7 +12,7 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do
let(:saml_provider) { create(:saml_provider) } let(:saml_provider) { create(:saml_provider) }
let(:session) { {} } let(:session) { {} }
subject { described_class.new(user, oauth, saml_provider, session) } subject { described_class.new(user, oauth, session, saml_provider) }
context 'linked identity exists' do context 'linked identity exists' do
let!(:identity) { user.identities.create!(provider: provider, extern_uid: uid, saml_provider: saml_provider) } let!(:identity) { user.identities.create!(provider: provider, extern_uid: uid, saml_provider: saml_provider) }
...@@ -37,7 +37,7 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do ...@@ -37,7 +37,7 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do
context 'identity needs to be created' do context 'identity needs to be created' do
context 'with identity provider initiated request' do context 'with identity provider initiated request' do
it 'attempting to link accounts raises an exception' do it 'attempting to link accounts raises an exception' do
expect { subject.link }.to raise_error(Gitlab::Auth::GroupSaml::IdentityLinker::UnverifiedRequest) expect { subject.link }.to raise_error(Gitlab::Auth::Saml::IdentityLinker::UnverifiedRequest)
end end
end end
......
...@@ -14,7 +14,7 @@ describe GroupSaml::SignUpService do ...@@ -14,7 +14,7 @@ describe GroupSaml::SignUpService do
before do before do
allow(Gitlab::Auth::GroupSaml::IdentityLinker) allow(Gitlab::Auth::GroupSaml::IdentityLinker)
.to receive(:new).with(new_user, session['oauth_data'], group.saml_provider, session) .to receive(:new).with(new_user, session['oauth_data'], session, group.saml_provider)
.and_return(linker_spy) .and_return(linker_spy)
end end
......
...@@ -20,7 +20,7 @@ module EE ...@@ -20,7 +20,7 @@ module EE
def mock_group_saml(uid:) def mock_group_saml(uid:)
allow(Devise).to receive(:omniauth_providers).and_return(%i(group_saml)) allow(Devise).to receive(:omniauth_providers).and_return(%i(group_saml))
allow_any_instance_of(::Gitlab::Auth::SamlOriginValidator).to receive(:gitlab_initiated?).and_return(true) allow_any_instance_of(::Gitlab::Auth::Saml::OriginValidator).to receive(:gitlab_initiated?).and_return(true)
configure_group_saml_mock_auth(uid: uid) configure_group_saml_mock_auth(uid: uid)
stub_omniauth_provider(:group_saml) stub_omniauth_provider(:group_saml)
end end
......
...@@ -3,12 +3,13 @@ ...@@ -3,12 +3,13 @@
module Gitlab module Gitlab
module Auth module Auth
class OmniauthIdentityLinkerBase class OmniauthIdentityLinkerBase
attr_reader :current_user, :oauth attr_reader :current_user, :oauth, :session
def initialize(current_user, oauth) def initialize(current_user, oauth, session = {})
@current_user = current_user @current_user = current_user
@oauth = oauth @oauth = oauth
@changed = false @changed = false
@session = session
end end
def link def link
......
...@@ -4,6 +4,30 @@ module Gitlab ...@@ -4,6 +4,30 @@ module Gitlab
module Auth module Auth
module Saml module Saml
class IdentityLinker < OmniauthIdentityLinkerBase class IdentityLinker < OmniauthIdentityLinkerBase
extend ::Gitlab::Utils::Override
UnverifiedRequest = Class.new(StandardError)
override :link
def link
raise_unless_request_is_gitlab_initiated! if unlinked?
super
end
protected
def raise_unless_request_is_gitlab_initiated!
raise UnverifiedRequest unless valid_gitlab_initiated_request?
end
def valid_gitlab_initiated_request?
OriginValidator.new(session).gitlab_initiated?(saml_response)
end
def saml_response
oauth.fetch(:extra, {}).fetch(:response_object, {})
end
end end
end end
end end
......
# frozen_string_literal: true
module Gitlab
module Auth
module Saml
class OriginValidator
AUTH_REQUEST_SESSION_KEY = "last_authn_request_id".freeze
def initialize(session)
@session = session || {}
end
def store_origin(authn_request)
session[AUTH_REQUEST_SESSION_KEY] = authn_request.uuid
end
def gitlab_initiated?(saml_response)
return false if identity_provider_initiated?(saml_response)
matches?(saml_response)
end
private
attr_reader :session
def matches?(saml_response)
saml_response.in_response_to == expected_request_id
end
def identity_provider_initiated?(saml_response)
saml_response.in_response_to.blank?
end
def expected_request_id
session[AUTH_REQUEST_SESSION_KEY]
end
end
end
end
end
# frozen_string_literal: true
module OmniAuth
module Strategies
class SAML
extend ::Gitlab::Utils::Override
# NOTE: This method duplicates code from omniauth-saml
# so that we can access authn_request to store it
# See: https://github.com/omniauth/omniauth-saml/issues/172
override :request_phase
def request_phase
authn_request = OneLogin::RubySaml::Authrequest.new
store_authn_request_id(authn_request)
with_settings do |settings|
redirect(authn_request.create(settings, additional_params_for_authn_request))
end
end
private
def store_authn_request_id(authn_request)
Gitlab::Auth::Saml::OriginValidator.new(session).store_origin(authn_request)
end
end
end
end
...@@ -13242,6 +13242,9 @@ msgstr "" ...@@ -13242,6 +13242,9 @@ msgstr ""
msgid "Request Access" msgid "Request Access"
msgstr "" msgstr ""
msgid "Request to link SAML account must be authorized"
msgstr ""
msgid "Requested %{time_ago}" msgid "Requested %{time_ago}"
msgstr "" msgstr ""
......
...@@ -247,16 +247,26 @@ describe OmniauthCallbacksController, type: :controller do ...@@ -247,16 +247,26 @@ describe OmniauthCallbacksController, type: :controller do
end end
describe '#saml' do describe '#saml' do
let(:last_request_id) { 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685' }
let(:user) { create(:omniauth_user, :two_factor, extern_uid: 'my-uid', provider: 'saml') } let(:user) { create(:omniauth_user, :two_factor, extern_uid: 'my-uid', provider: 'saml') }
let(:mock_saml_response) { File.read('spec/fixtures/authentication/saml_response.xml') } let(:mock_saml_response) { File.read('spec/fixtures/authentication/saml_response.xml') }
let(:saml_config) { mock_saml_config_with_upstream_two_factor_authn_contexts } let(:saml_config) { mock_saml_config_with_upstream_two_factor_authn_contexts }
def stub_last_request_id(id)
session['last_authn_request_id'] = id
end
before do before do
stub_last_request_id(last_request_id)
stub_omniauth_saml_config({ enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'], stub_omniauth_saml_config({ enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'],
providers: [saml_config] }) providers: [saml_config] })
mock_auth_hash_with_saml_xml('saml', +'my-uid', user.email, mock_saml_response) mock_auth_hash_with_saml_xml('saml', +'my-uid', user.email, mock_saml_response)
request.env["devise.mapping"] = Devise.mappings[:user] request.env['devise.mapping'] = Devise.mappings[:user]
request.env['omniauth.auth'] = Rails.application.env_config['omniauth.auth'] request.env['omniauth.auth'] = Rails.application.env_config['omniauth.auth']
end
context 'with GitLab initiated request' do
before do
post :saml, params: { SAMLResponse: mock_saml_response } post :saml, params: { SAMLResponse: mock_saml_response }
end end
...@@ -278,4 +288,30 @@ describe OmniauthCallbacksController, type: :controller do ...@@ -278,4 +288,30 @@ describe OmniauthCallbacksController, type: :controller do
end end
end end
end end
context 'with IdP initiated request' do
let(:user) { create(:user) }
let(:last_request_id) { '99999' }
before do
sign_in user
end
it 'lets the user know their account isn\'t linked yet' do
post :saml, params: { SAMLResponse: mock_saml_response }
expect(flash[:notice]).to eq 'Request to link SAML account must be authorized'
end
it 'redirects to profile account page' do
post :saml, params: { SAMLResponse: mock_saml_response }
expect(response).to redirect_to(profile_account_path)
end
it 'doesn\'t link a new identity to the user' do
expect { post :saml, params: { SAMLResponse: mock_saml_response } }.not_to change { user.identities.count }
end
end
end
end end
...@@ -6,10 +6,17 @@ describe Gitlab::Auth::Saml::IdentityLinker do ...@@ -6,10 +6,17 @@ describe Gitlab::Auth::Saml::IdentityLinker do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:provider) { 'saml' } let(:provider) { 'saml' }
let(:uid) { user.email } let(:uid) { user.email }
let(:oauth) { { 'provider' => provider, 'uid' => uid } } let(:in_response_to) { '12345' }
let(:saml_response) { instance_double(OneLogin::RubySaml::Response, in_response_to: in_response_to) }
let(:session) { { 'last_authn_request_id' => in_response_to } }
subject { described_class.new(user, oauth) } let(:oauth) do
OmniAuth::AuthHash.new(provider: provider, uid: uid, extra: { response_object: saml_response })
end
subject { described_class.new(user, oauth, session) }
context 'with valid GitLab initiated request' do
context 'linked identity exists' do context 'linked identity exists' do
let!(:identity) { user.identities.create!(provider: provider, extern_uid: uid) } let!(:identity) { user.identities.create!(provider: provider, extern_uid: uid) }
...@@ -47,4 +54,13 @@ describe Gitlab::Auth::Saml::IdentityLinker do ...@@ -47,4 +54,13 @@ describe Gitlab::Auth::Saml::IdentityLinker do
expect(subject).to be_changed expect(subject).to be_changed
end end
end end
end
context 'with identity provider initiated request' do
let(:session) { { 'last_authn_request_id' => nil } }
it 'attempting to link accounts raises an exception' do
expect { subject.link }.to raise_error(Gitlab::Auth::Saml::IdentityLinker::UnverifiedRequest)
end
end
end end
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe Gitlab::Auth::SamlOriginValidator do describe Gitlab::Auth::Saml::OriginValidator do
let(:session) { instance_double(ActionDispatch::Request::Session) } let(:session) { instance_double(ActionDispatch::Request::Session) }
subject { described_class.new(session) } subject { described_class.new(session) }
......
require 'spec_helper'
describe OmniAuth::Strategies::SAML, type: :strategy do
let(:idp_sso_target_url) { 'https://login.example.com/idp' }
let(:strategy) { [OmniAuth::Strategies::SAML, { idp_sso_target_url: idp_sso_target_url }] }
describe 'POST /users/auth/saml' do
it 'redirects to the provider login page' do
post '/users/auth/saml'
expect(last_response).to redirect_to(/\A#{Regexp.quote(idp_sso_target_url)}/)
end
it 'stores request ID during request phase' do
request_id = double
allow_any_instance_of(OneLogin::RubySaml::Authrequest).to receive(:uuid).and_return(request_id)
post '/users/auth/saml'
expect(session['last_authn_request_id']).to eq(request_id)
end
end
end
...@@ -8,7 +8,7 @@ module StrategyHelpers ...@@ -8,7 +8,7 @@ module StrategyHelpers
def post(*args) def post(*args)
super.tap do super.tap do
@response = ActionDispatch::TestResponse.from_response(last_response) # rubocop:disable Gitlab/ModuleWithInstanceVariables @response = ActionDispatch::TestResponse.from_response(last_response)
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