Commit 3132550d authored by James Edwards-Jones's avatar James Edwards-Jones Committed by Jarka Košanová

Verify Group SAML linking originates from GitLab

Ensure it isn't possible to link accounts with a request that starts at
the identity provider, as this would allow a malicious group or IdP to
link accounts for arbitrary users tricked into visiting that IdP.
parent 8821b8be
...@@ -9,9 +9,11 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController ...@@ -9,9 +9,11 @@ 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) identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(current_user, oauth, @saml_provider, session)
omniauth_flow(Gitlab::Auth::GroupSaml, identity_linker: identity_linker) omniauth_flow(Gitlab::Auth::GroupSaml, identity_linker: identity_linker)
rescue Gitlab::Auth::GroupSaml::IdentityLinker::UnverifiedRequest
redirect_unverified_saml_initiation
end end
private private
...@@ -44,6 +46,12 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController ...@@ -44,6 +46,12 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController
super super
end end
def redirect_unverified_saml_initiation
flash[:notice] = "Request to link SAML account must be authorized"
redirect_to sso_group_saml_providers_path(@unauthenticated_group)
end
override :after_sign_in_path_for override :after_sign_in_path_for
def after_sign_in_path_for(resource) def after_sign_in_path_for(resource)
saml_redirect_path || super saml_redirect_path || super
......
---
title: Prevent Group SAML authorizing sign in without prior user approval
merge_request:
author:
type: security
...@@ -4,15 +4,20 @@ module Gitlab ...@@ -4,15 +4,20 @@ 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 attr_reader :saml_provider, :session
def initialize(current_user, oauth, saml_provider) UnverifiedRequest = Class.new(StandardError)
def initialize(current_user, oauth, saml_provider, session)
super(current_user, oauth) super(current_user, oauth)
@saml_provider = saml_provider @saml_provider = saml_provider
@session = session
end end
def link def link
raise_unless_request_is_gitlab_initiated! if unlinked?
super super
update_group_membership unless failed? update_group_membership unless failed?
...@@ -32,6 +37,18 @@ module Gitlab ...@@ -32,6 +37,18 @@ 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
...@@ -36,6 +36,20 @@ module OmniAuth ...@@ -36,6 +36,20 @@ 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 self.invalid_group!(path) def self.invalid_group!(path)
raise ActionController::RoutingError, path raise ActionController::RoutingError, path
end end
...@@ -54,6 +68,10 @@ module OmniAuth ...@@ -54,6 +68,10 @@ module OmniAuth
Feature.enabled?(:group_saml_metadata_available, group_lookup.group) Feature.enabled?(:group_saml_metadata_available, group_lookup.group)
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
......
...@@ -9,6 +9,9 @@ describe Groups::OmniauthCallbacksController do ...@@ -9,6 +9,9 @@ describe Groups::OmniauthCallbacksController do
let(:provider) { :group_saml } let(:provider) { :group_saml }
let(:group) { create(:group, :private) } let(:group) { create(:group, :private) }
let!(:saml_provider) { create(:saml_provider, group: group) } let!(:saml_provider) { create(:saml_provider, group: group) }
let(:in_response_to) { '12345' }
let(:last_request_id) { in_response_to }
let(:saml_response) { instance_double(OneLogin::RubySaml::Response, in_response_to: in_response_to) }
before do before do
stub_licensed_features(group_saml: true) stub_licensed_features(group_saml: true)
...@@ -22,6 +25,10 @@ describe Groups::OmniauthCallbacksController do ...@@ -22,6 +25,10 @@ describe Groups::OmniauthCallbacksController do
create(:omniauth_user, extern_uid: uid, provider: provider, saml_provider: saml_provider) create(:omniauth_user, extern_uid: uid, provider: provider, saml_provider: saml_provider)
end end
def stub_last_request_id(id)
session["last_authn_request_id"] = id
end
context "when request hasn't been validated by omniauth middleware" do context "when request hasn't been validated by omniauth middleware" do
it "prevents authentication" do it "prevents authentication" do
sign_in(user) sign_in(user)
...@@ -34,8 +41,9 @@ describe Groups::OmniauthCallbacksController do ...@@ -34,8 +41,9 @@ describe Groups::OmniauthCallbacksController do
context "valid credentials" do context "valid credentials" do
before do before do
mock_auth_hash(provider, uid, user.email) mock_auth_hash(provider, uid, user.email, response_object: saml_response)
stub_omniauth_provider(provider, context: request) stub_omniauth_provider(provider, context: request)
stub_last_request_id(last_request_id)
end end
shared_examples "and identity already linked" do shared_examples "and identity already linked" do
...@@ -104,6 +112,22 @@ describe Groups::OmniauthCallbacksController do ...@@ -104,6 +112,22 @@ describe Groups::OmniauthCallbacksController do
expect(flash[:notice]).to match(/SAML for .* was added/) expect(flash[:notice]).to match(/SAML for .* was added/)
end end
context 'with IdP initiated request' do
let(:last_request_id) { '99999' }
it 'redirects to account link page' do
post provider, params: { group_id: group }
expect(response).to redirect_to(sso_group_saml_providers_path(group))
end
it "lets the user know their account isn't linked yet" do
post provider, params: { group_id: group }
expect(flash[:notice]).to eq 'Request to link SAML account must be authorized'
end
end
end end
end end
......
...@@ -20,7 +20,9 @@ describe 'Profile > Account' do ...@@ -20,7 +20,9 @@ describe 'Profile > Account' do
def create_linked_identity def create_linked_identity
oauth = { 'provider' => 'group_saml', 'uid' => '1' } oauth = { 'provider' => 'group_saml', 'uid' => '1' }
Gitlab::Auth::GroupSaml::IdentityLinker.new(user, oauth, saml_provider).link identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(user, oauth, saml_provider, double(:session))
allow(identity_linker).to receive(:valid_gitlab_initated_request?).and_return(true)
identity_linker.link
end end
before do before do
......
...@@ -4,10 +4,13 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do ...@@ -4,10 +4,13 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:provider) { 'group_saml' } let(:provider) { 'group_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(:oauth) { OmniAuth::AuthHash.new(provider: provider, uid: uid, extra: { response_object: saml_response }) }
let(:saml_provider) { create(:saml_provider) } let(:saml_provider) { create(:saml_provider) }
let(:session) { {} }
subject { described_class.new(user, oauth, saml_provider) } subject { described_class.new(user, oauth, saml_provider, session) }
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) }
...@@ -30,6 +33,15 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do ...@@ -30,6 +33,15 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do
end end
context 'identity needs to be created' do context 'identity needs to be created' do
context 'with identity provider initiated request' do
it 'attempting to link accounts raises an exception' do
expect { subject.link }.to raise_error(Gitlab::Auth::GroupSaml::IdentityLinker::UnverifiedRequest)
end
end
context 'with valid gitlab initiated request' do
let(:session) { { 'last_authn_request_id' => in_response_to } }
it 'creates linked identity' do it 'creates linked identity' do
expect { subject.link }.to change { user.identities.count } expect { subject.link }.to change { user.identities.count }
end end
...@@ -64,4 +76,5 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do ...@@ -64,4 +76,5 @@ describe Gitlab::Auth::GroupSaml::IdentityLinker do
expect(saml_provider.group.member?(user)).to eq(true) expect(saml_provider.group.member?(user)).to eq(true)
end end
end end
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Auth::SamlOriginValidator do
let(:session) { instance_double(ActionDispatch::Request::Session) }
subject { described_class.new(session) }
describe '#store_origin' do
it 'stores the SAML request ID' do
request_id = double
authn_request = instance_double(OneLogin::RubySaml::Authrequest, uuid: request_id)
expect(session).to receive(:[]=).with('last_authn_request_id', request_id)
subject.store_origin(authn_request)
end
end
describe '#gitlab_initiated?' do
it 'returns false if InResponseTo is not present' do
saml_response = instance_double(OneLogin::RubySaml::Response, in_response_to: nil)
expect(subject.gitlab_initiated?(saml_response)).to eq(false)
end
it 'returns false if InResponseTo does not match stored value' do
saml_response = instance_double(OneLogin::RubySaml::Response, in_response_to: "abc")
allow(session).to receive(:[]).with('last_authn_request_id').and_return('123')
expect(subject.gitlab_initiated?(saml_response)).to eq(false)
end
it 'returns true if InResponseTo matches stored value' do
saml_response = instance_double(OneLogin::RubySaml::Response, in_response_to: "123")
allow(session).to receive(:[]).with('last_authn_request_id').and_return('123')
expect(subject.gitlab_initiated?(saml_response)).to eq(true)
end
end
end
...@@ -110,6 +110,15 @@ describe OmniAuth::Strategies::GroupSaml, type: :strategy do ...@@ -110,6 +110,15 @@ describe OmniAuth::Strategies::GroupSaml, type: :strategy do
post '/users/auth/group_saml' post '/users/auth/group_saml'
end.to raise_error(ActionController::RoutingError) end.to raise_error(ActionController::RoutingError)
end 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/group_saml', group_path: 'my-group'
expect(session['last_authn_request_id']).to eq(request_id)
end
end end
describe 'POST /users/auth/group_saml/metadata' do describe 'POST /users/auth/group_saml/metadata' do
......
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