ff632a81fb
result3 does not invoke message.clean() when an exception is thrown by `message.connection.result3()` call, causing pool connection associated with the message to be marked active forever. This causes a denial-of-service on ldappool. The fix ensures message.clean() is invoked by wrapping the offending call in try-except-finally and putting the message.clean() in finally block. Closes-Bug: #1998789 Change-Id: I59ebf0fa77391d49b2349e918fc55f96318c42a6 Signed-off-by: Mustafa Kemal Gilor <mustafa.gilor@canonical.com>
345 lines
14 KiB
Python
345 lines
14 KiB
Python
# Copyright 2012 OpenStack Foundation
|
|
# Copyright 2013 IBM Corp.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
from unittest import mock
|
|
|
|
import fixtures
|
|
import ldappool
|
|
|
|
from keystone.common import provider_api
|
|
import keystone.conf
|
|
from keystone.identity.backends import ldap
|
|
from keystone.identity.backends.ldap import common as common_ldap
|
|
from keystone.tests import unit
|
|
from keystone.tests.unit import fakeldap
|
|
from keystone.tests.unit import test_backend_ldap
|
|
|
|
|
|
CONF = keystone.conf.CONF
|
|
PROVIDERS = provider_api.ProviderAPIs
|
|
|
|
|
|
class LdapPoolCommonTestMixin(object):
|
|
"""LDAP pool specific common tests used here and in live tests."""
|
|
|
|
def cleanup_pools(self):
|
|
common_ldap.PooledLDAPHandler.connection_pools.clear()
|
|
|
|
def test_handler_with_use_pool_enabled(self):
|
|
# by default use_pool and use_auth_pool is enabled in test pool config
|
|
user_ref = PROVIDERS.identity_api.get_user(self.user_foo['id'])
|
|
self.user_foo.pop('password')
|
|
self.assertDictEqual(self.user_foo, user_ref)
|
|
|
|
handler = common_ldap._get_connection(CONF.ldap.url, use_pool=True)
|
|
self.assertIsInstance(handler, common_ldap.PooledLDAPHandler)
|
|
|
|
@mock.patch.object(common_ldap.KeystoneLDAPHandler, 'connect')
|
|
@mock.patch.object(common_ldap.KeystoneLDAPHandler, 'simple_bind_s')
|
|
def test_handler_with_use_pool_not_enabled(self, bind_method,
|
|
connect_method):
|
|
self.config_fixture.config(group='ldap', use_pool=False)
|
|
self.config_fixture.config(group='ldap', use_auth_pool=True)
|
|
self.cleanup_pools()
|
|
|
|
user_api = ldap.UserApi(CONF)
|
|
handler = user_api.get_connection(user=None, password=None,
|
|
end_user_auth=True)
|
|
# use_auth_pool flag does not matter when use_pool is False
|
|
# still handler is non pool version
|
|
self.assertIsInstance(handler.conn, common_ldap.PythonLDAPHandler)
|
|
|
|
@mock.patch.object(common_ldap.KeystoneLDAPHandler, 'connect')
|
|
@mock.patch.object(common_ldap.KeystoneLDAPHandler, 'simple_bind_s')
|
|
def test_handler_with_end_user_auth_use_pool_not_enabled(self, bind_method,
|
|
connect_method):
|
|
# by default use_pool is enabled in test pool config
|
|
# now disabling use_auth_pool flag to test handler instance
|
|
self.config_fixture.config(group='ldap', use_auth_pool=False)
|
|
self.cleanup_pools()
|
|
|
|
user_api = ldap.UserApi(CONF)
|
|
handler = user_api.get_connection(user=None, password=None,
|
|
end_user_auth=True)
|
|
self.assertIsInstance(handler.conn, common_ldap.PythonLDAPHandler)
|
|
|
|
# For end_user_auth case, flag should not be false otherwise
|
|
# it will use, admin connections ldap pool
|
|
handler = user_api.get_connection(user=None, password=None,
|
|
end_user_auth=False)
|
|
self.assertIsInstance(handler.conn, common_ldap.PooledLDAPHandler)
|
|
|
|
def test_pool_size_set(self):
|
|
# get related connection manager instance
|
|
ldappool_cm = self.conn_pools[CONF.ldap.url]
|
|
self.assertEqual(CONF.ldap.pool_size, ldappool_cm.size)
|
|
|
|
def test_pool_retry_max_set(self):
|
|
# get related connection manager instance
|
|
ldappool_cm = self.conn_pools[CONF.ldap.url]
|
|
self.assertEqual(CONF.ldap.pool_retry_max, ldappool_cm.retry_max)
|
|
|
|
def test_pool_retry_delay_set(self):
|
|
# just make one identity call to initiate ldap connection if not there
|
|
PROVIDERS.identity_api.get_user(self.user_foo['id'])
|
|
|
|
# get related connection manager instance
|
|
ldappool_cm = self.conn_pools[CONF.ldap.url]
|
|
self.assertEqual(CONF.ldap.pool_retry_delay, ldappool_cm.retry_delay)
|
|
|
|
def test_pool_use_tls_set(self):
|
|
# get related connection manager instance
|
|
ldappool_cm = self.conn_pools[CONF.ldap.url]
|
|
self.assertEqual(CONF.ldap.use_tls, ldappool_cm.use_tls)
|
|
|
|
def test_pool_timeout_set(self):
|
|
# get related connection manager instance
|
|
ldappool_cm = self.conn_pools[CONF.ldap.url]
|
|
self.assertEqual(CONF.ldap.pool_connection_timeout,
|
|
ldappool_cm.timeout)
|
|
|
|
def test_pool_use_pool_set(self):
|
|
# get related connection manager instance
|
|
ldappool_cm = self.conn_pools[CONF.ldap.url]
|
|
self.assertEqual(CONF.ldap.use_pool, ldappool_cm.use_pool)
|
|
|
|
def test_pool_connection_lifetime_set(self):
|
|
# get related connection manager instance
|
|
ldappool_cm = self.conn_pools[CONF.ldap.url]
|
|
self.assertEqual(CONF.ldap.pool_connection_lifetime,
|
|
ldappool_cm.max_lifetime)
|
|
|
|
def test_max_connection_error_raised(self):
|
|
|
|
who = CONF.ldap.user
|
|
cred = CONF.ldap.password
|
|
# get related connection manager instance
|
|
ldappool_cm = self.conn_pools[CONF.ldap.url]
|
|
ldappool_cm.size = 2
|
|
|
|
# 3rd connection attempt should raise Max connection error
|
|
with ldappool_cm.connection(who, cred) as _: # conn1
|
|
with ldappool_cm.connection(who, cred) as _: # conn2
|
|
try:
|
|
with ldappool_cm.connection(who, cred) as _: # conn3
|
|
_.unbind_s()
|
|
self.fail()
|
|
except Exception as ex:
|
|
self.assertIsInstance(ex,
|
|
ldappool.MaxConnectionReachedError)
|
|
ldappool_cm.size = CONF.ldap.pool_size
|
|
|
|
def test_pool_size_expands_correctly(self):
|
|
|
|
who = CONF.ldap.user
|
|
cred = CONF.ldap.password
|
|
# get related connection manager instance
|
|
ldappool_cm = self.conn_pools[CONF.ldap.url]
|
|
ldappool_cm.size = 3
|
|
|
|
def _get_conn():
|
|
return ldappool_cm.connection(who, cred)
|
|
|
|
# Open 3 connections first
|
|
with _get_conn() as _: # conn1
|
|
self.assertEqual(1, len(ldappool_cm))
|
|
with _get_conn() as _: # conn2
|
|
self.assertEqual(2, len(ldappool_cm))
|
|
with _get_conn() as _: # conn2
|
|
_.unbind_ext_s()
|
|
self.assertEqual(3, len(ldappool_cm))
|
|
|
|
# Then open 3 connections again and make sure size does not grow
|
|
# over 3
|
|
with _get_conn() as c1: # conn1
|
|
self.assertEqual(3, len(ldappool_cm))
|
|
c1.connected = False
|
|
with _get_conn() as c2: # conn2
|
|
self.assertEqual(3, len(ldappool_cm))
|
|
c2.connected = False
|
|
with _get_conn() as c3: # conn3
|
|
c3.connected = False
|
|
c3.unbind_ext_s()
|
|
self.assertEqual(3, len(ldappool_cm))
|
|
|
|
with _get_conn() as c1: # conn1
|
|
self.assertEqual(1, len(ldappool_cm))
|
|
with _get_conn() as c2: # conn2
|
|
self.assertEqual(2, len(ldappool_cm))
|
|
with _get_conn() as c3: # conn3
|
|
c3.unbind_ext_s()
|
|
self.assertEqual(3, len(ldappool_cm))
|
|
|
|
def test_password_change_with_pool(self):
|
|
old_password = self.user_sna['password']
|
|
self.cleanup_pools()
|
|
|
|
# authenticate so that connection is added to pool before password
|
|
# change
|
|
with self.make_request():
|
|
user_ref = PROVIDERS.identity_api.authenticate(
|
|
user_id=self.user_sna['id'],
|
|
password=self.user_sna['password'])
|
|
|
|
self.user_sna.pop('password')
|
|
self.user_sna['enabled'] = True
|
|
self.assertUserDictEqual(self.user_sna, user_ref)
|
|
|
|
new_password = 'new_password'
|
|
user_ref['password'] = new_password
|
|
PROVIDERS.identity_api.update_user(user_ref['id'], user_ref)
|
|
|
|
# now authenticate again to make sure new password works with
|
|
# connection pool
|
|
with self.make_request():
|
|
user_ref2 = PROVIDERS.identity_api.authenticate(
|
|
user_id=self.user_sna['id'],
|
|
password=new_password)
|
|
|
|
user_ref.pop('password')
|
|
self.assertUserDictEqual(user_ref, user_ref2)
|
|
|
|
# Authentication with old password would not work here as there
|
|
# is only one connection in pool which get bind again with updated
|
|
# password..so no old bind is maintained in this case.
|
|
with self.make_request():
|
|
self.assertRaises(AssertionError,
|
|
PROVIDERS.identity_api.authenticate,
|
|
user_id=self.user_sna['id'],
|
|
password=old_password)
|
|
|
|
@mock.patch.object(fakeldap.FakeLdap, 'search_ext')
|
|
def test_search_ext_ensure_pool_connection_released(self, mock_search_ext):
|
|
"""Test search_ext exception resiliency.
|
|
|
|
Call search_ext function in isolation. Doing so will cause
|
|
search_ext to borrow a connection from the pool and associate
|
|
it with an AsynchronousMessage object. Borrowed connection ought
|
|
to be released if anything goes wrong during LDAP API call. This
|
|
test case intentionally throws an exception to ensure everything
|
|
goes as expected when LDAP connection raises an exception.
|
|
"""
|
|
class CustomDummyException(Exception):
|
|
pass
|
|
|
|
# Throw an exception intentionally when LDAP
|
|
# connection search_ext function is called
|
|
mock_search_ext.side_effect = CustomDummyException()
|
|
self.config_fixture.config(group='ldap', pool_size=1)
|
|
pool = self.conn_pools[CONF.ldap.url]
|
|
user_api = ldap.UserApi(CONF)
|
|
|
|
# setUp primes the pool so pool
|
|
# must have one connection
|
|
self.assertEqual(1, len(pool))
|
|
for i in range(1, 10):
|
|
handler = user_api.get_connection()
|
|
# Just to ensure that we're using pooled connections
|
|
self.assertIsInstance(handler.conn, common_ldap.PooledLDAPHandler)
|
|
# LDAP API will throw CustomDummyException. In this scenario
|
|
# we expect LDAP connection to be made available back to the
|
|
# pool.
|
|
self.assertRaises(
|
|
CustomDummyException,
|
|
lambda: handler.search_ext(
|
|
'dc=example,dc=test',
|
|
'dummy',
|
|
'objectclass=*',
|
|
['mail', 'userPassword']
|
|
)
|
|
)
|
|
# Pooled connection must not be evicted from the pool
|
|
self.assertEqual(1, len(pool))
|
|
# Ensure that the connection is inactive afterwards
|
|
with pool._pool_lock:
|
|
for slot, conn in enumerate(pool._pool):
|
|
self.assertFalse(conn.active)
|
|
|
|
self.assertEqual(mock_search_ext.call_count, i)
|
|
|
|
@mock.patch.object(fakeldap.FakeLdap, 'result3')
|
|
def test_result3_ensure_pool_connection_released(self, mock_result3):
|
|
"""Test search_ext-->result3 exception resiliency.
|
|
|
|
Call search_ext function, grab an AsynchronousMessage object and
|
|
call result3 with it. During the result3 call, LDAP API will throw
|
|
an exception.The expectation is that the associated LDAP pool
|
|
connection for AsynchronousMessage must be released back to the
|
|
LDAP connection pool.
|
|
"""
|
|
class CustomDummyException(Exception):
|
|
pass
|
|
|
|
# Throw an exception intentionally when LDAP
|
|
# connection result3 function is called
|
|
mock_result3.side_effect = CustomDummyException()
|
|
self.config_fixture.config(group='ldap', pool_size=1)
|
|
pool = self.conn_pools[CONF.ldap.url]
|
|
user_api = ldap.UserApi(CONF)
|
|
|
|
# setUp primes the pool so pool
|
|
# must have one connection
|
|
self.assertEqual(1, len(pool))
|
|
for i in range(1, 10):
|
|
handler = user_api.get_connection()
|
|
# Just to ensure that we're using pooled connections
|
|
self.assertIsInstance(handler.conn, common_ldap.PooledLDAPHandler)
|
|
msg = handler.search_ext(
|
|
'dc=example,dc=test',
|
|
'dummy',
|
|
'objectclass=*',
|
|
['mail', 'userPassword']
|
|
)
|
|
# Connection is in use, must be already marked active
|
|
self.assertTrue(msg.connection.active)
|
|
# Pooled connection must not be evicted from the pool
|
|
self.assertEqual(1, len(pool))
|
|
# LDAP API will throw CustomDummyException. In this
|
|
# scenario we expect LDAP connection to be made
|
|
# available back to the pool.
|
|
self.assertRaises(
|
|
CustomDummyException,
|
|
lambda: handler.result3(msg)
|
|
)
|
|
# Connection must be set inactive
|
|
self.assertFalse(msg.connection.active)
|
|
# Pooled connection must not be evicted from the pool
|
|
self.assertEqual(1, len(pool))
|
|
self.assertEqual(mock_result3.call_count, i)
|
|
|
|
|
|
class LDAPIdentity(LdapPoolCommonTestMixin,
|
|
test_backend_ldap.LDAPIdentity,
|
|
unit.TestCase):
|
|
"""Executes tests in existing base class with pooled LDAP handler."""
|
|
|
|
def setUp(self):
|
|
self.useFixture(fixtures.MockPatchObject(
|
|
common_ldap.PooledLDAPHandler, 'Connector', fakeldap.FakeLdapPool))
|
|
super(LDAPIdentity, self).setUp()
|
|
|
|
self.addCleanup(self.cleanup_pools)
|
|
# storing to local variable to avoid long references
|
|
self.conn_pools = common_ldap.PooledLDAPHandler.connection_pools
|
|
# super class loads db fixtures which establishes ldap connection
|
|
# so adding dummy call to highlight connection pool initialization
|
|
# as its not that obvious though its not needed here
|
|
PROVIDERS.identity_api.get_user(self.user_foo['id'])
|
|
|
|
def config_files(self):
|
|
config_files = super(LDAPIdentity, self).config_files()
|
|
config_files.append(unit.dirs.tests_conf('backend_ldap_pool.conf'))
|
|
return config_files
|