Moderate severityNVD Advisory· Published Nov 3, 2014· Updated May 6, 2026
CVE-2014-0204
CVE-2014-0204
Description
OpenStack Identity (Keystone) before 2014.1.1 does not properly handle when a role is assigned to a group that has the same ID as a user, which allows remote authenticated users to gain privileges that are assigned to a group with the same ID.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
keystonePyPI | < 8.0.0a0 | 8.0.0a0 |
Affected products
1Patches
4f0eee2f3b48dRemove obsolete note from ldap
1 file changed · +0 −6
keystone/assignment/backends/ldap.py+0 −6 modified@@ -98,12 +98,6 @@ def _get_roles_for_just_user_and_project(user_id, tenant_id): def _get_roles_for_group_and_project(group_id, project_id): self.get_project(project_id) group_dn = self.group._id_to_dn(group_id) - # NOTE(marcos-fermin-lobo): In Active Directory, for functions - # such as "self.role.get_role_assignments", it returns - # the key "CN" or "OU" in uppercase. - # The group_dn var has "CN" and "OU" in lowercase. - # For this reason, it is necessary to use the "upper()" - # function so both are consistent. return [self.role._dn_to_id(a.role_dn) for a in self.role.get_role_assignments (self.project._id_to_dn(project_id))
729dcad7384bLDAP fix for get_roles_for_user_and_project user=group ID
5 files changed · +318 −42
keystone/assignment/backends/ldap.py+3 −2 modified@@ -89,10 +89,11 @@ def _get_metadata(self, user_id=None, tenant_id=None, def _get_roles_for_just_user_and_project(user_id, tenant_id): self.get_project(tenant_id) + user_dn = self.user._id_to_dn(user_id) return [self.role._dn_to_id(a.role_dn) for a in self.role.get_role_assignments (self.project._id_to_dn(tenant_id)) - if self.user._dn_to_id(a.user_dn) == user_id] + if common_ldap.is_dn_equal(a.user_dn, user_dn)] def _get_roles_for_group_and_project(group_id, project_id): self.get_project(project_id) @@ -106,7 +107,7 @@ def _get_roles_for_group_and_project(group_id, project_id): return [self.role._dn_to_id(a.role_dn) for a in self.role.get_role_assignments (self.project._id_to_dn(project_id)) - if a.user_dn.upper() == group_dn.upper()] + if common_ldap.is_dn_equal(a.user_dn, group_dn)] if domain_id is not None: msg = _('Domain metadata not supported by LDAP')
keystone/common/ldap/core.py+110 −0 modified@@ -14,6 +14,7 @@ import abc import os.path +import re import codecs import ldap @@ -141,6 +142,115 @@ def ldap_scope(scope): 'options': ', '.join(LDAP_SCOPES.keys())}) +def prep_case_insensitive(value): + """Prepare a string for case-insensitive comparison. + + This is defined in RFC4518. For simplicity, all this function does is + lowercase all the characters, strip leading and trailing whitespace, + and compress sequences of spaces to a single space. + """ + value = re.sub(r'\s+', ' ', value.strip().lower()) + return value + + +def is_ava_value_equal(attribute_type, val1, val2): + """Returns True if and only if the AVAs are equal. + + When comparing AVAs, the equality matching rule for the attribute type + should be taken into consideration. For simplicity, this implementation + does a case-insensitive comparison. + + Note that this function uses prep_case_insenstive so the limitations of + that function apply here. + + """ + + return prep_case_insensitive(val1) == prep_case_insensitive(val2) + + +def is_rdn_equal(rdn1, rdn2): + """Returns True if and only if the RDNs are equal. + + * RDNs must have the same number of AVAs. + * Each AVA of the RDNs must be the equal for the same attribute type. The + order isn't significant. Note that an attribute type will only be in one + AVA in an RDN, otherwise the DN wouldn't be valid. + * Attribute types aren't case sensitive. Note that attribute type + comparison is more complicated than implemented. This function only + compares case-insentive. The code should handle multiple names for an + attribute type (e.g., cn, commonName, and 2.5.4.3 are the same). + + Note that this function uses is_ava_value_equal to compare AVAs so the + limitations of that function apply here. + + """ + + if len(rdn1) != len(rdn2): + return False + + for attr_type_1, val1, dummy in rdn1: + found = False + for attr_type_2, val2, dummy in rdn2: + if attr_type_1.lower() != attr_type_2.lower(): + continue + + found = True + if not is_ava_value_equal(attr_type_1, val1, val2): + return False + break + if not found: + return False + + return True + + +def is_dn_equal(dn1, dn2): + """Returns True if and only if the DNs are equal. + + Two DNs are equal if they've got the same number of RDNs and if the RDNs + are the same at each position. See RFC4517. + + Note that this function uses is_rdn_equal to compare RDNs so the + limitations of that function apply here. + + :param dn1: Either a string DN or a DN parsed by ldap.dn.str2dn. + :param dn2: Either a string DN or a DN parsed by ldap.dn.str2dn. + + """ + + if not isinstance(dn1, list): + dn1 = ldap.dn.str2dn(dn1) + if not isinstance(dn2, list): + dn2 = ldap.dn.str2dn(dn2) + + if len(dn1) != len(dn2): + return False + + for rdn1, rdn2 in zip(dn1, dn2): + if not is_rdn_equal(rdn1, rdn2): + return False + return True + + +def dn_startswith(descendant_dn, dn): + """Returns True if and only if the descendant_dn is under the dn. + + :param descendant_dn: Either a string DN or a DN parsed by ldap.dn.str2dn. + :param dn: Either a string DN or a DN parsed by ldap.dn.str2dn. + + """ + + if not isinstance(descendant_dn, list): + descendant_dn = ldap.dn.str2dn(descendant_dn) + if not isinstance(dn, list): + dn = ldap.dn.str2dn(dn) + + if len(descendant_dn) <= len(dn): + return False + + return is_dn_equal(descendant_dn[len(dn):], dn) + + @six.add_metaclass(abc.ABCMeta) class LDAPHandler(object): '''Abstract class which defines methods for a LDAP API provider.
keystone/tests/fakeldap.py+8 −1 modified@@ -62,6 +62,11 @@ def normalize_dn(dn): if dn == 'cn=Doe\\5c, John,ou=Users,cn=example,cn=com': return 'CN=Doe\\, John,OU=Users,CN=example,CN=com' + # NOTE(blk-u): Another special case for this tested value. When a + # roleOccupant has an escaped comma, it gets converted to \2C. + if dn == 'cn=Doe\\, John,ou=Users,cn=example,cn=com': + return 'CN=Doe\\2C John,OU=Users,CN=example,CN=com' + dn = ldap.dn.str2dn(core.utf8_encode(dn)) norm = [] for part in dn: @@ -133,7 +138,9 @@ def _match(key, value, attrs): str_sids = [six.text_type(x) for x in attrs[key]] return six.text_type(value) in str_sids if key != 'objectclass': - return _internal_attr(key, value)[0] in attrs[key] + check_value = _internal_attr(key, value)[0] + norm_values = list(_internal_attr(key, x)[0] for x in attrs[key]) + return check_value in norm_values # it is an objectclass check, so check subclasses values = _subs(value) for v in values:
keystone/tests/test_backend_ldap.py+28 −39 modified@@ -594,6 +594,34 @@ def test_user_id_comma(self): self.assertThat(ref_list, matchers.Equals([group])) + def test_user_id_comma_grants(self): + """Even if the user has a , in their ID, can get user and group grants. + """ + + # Create a user with a , in their ID + # NOTE(blk-u): the DN for this user is hard-coded in fakeldap! + user_id = u'Doe, John' + user = { + 'id': user_id, + 'name': self.getUniqueString(), + 'password': self.getUniqueString(), + 'domain_id': CONF.identity.default_domain_id, + } + self.identity_api.create_user(user_id, user) + + # Grant the user a role on a project. + + role_id = 'member' + project_id = self.tenant_baz['id'] + + self.assignment_api.create_grant(role_id, user_id=user_id, + project_id=project_id) + + role_ref = self.assignment_api.get_grant(role_id, user_id=user_id, + project_id=project_id) + + self.assertEqual(role_id, role_ref['id']) + class LDAPIdentity(BaseLDAPIdentity, tests.TestCase): @@ -1250,45 +1278,6 @@ def test_multi_role_grant_by_user_group_on_project_domain(self): user1['id'], CONF.identity.default_domain_id) self.assertEqual(0, len(combined_role_list)) - def test_get_roles_for_user_and_project_user_group_same_id(self): - """When a user has the same ID as a group, - get_roles_for_user_and_project returns the roles for the group. - - Overriding this test for LDAP because it works differently. The role - for the group is returned. This is bug 1309228. - """ - - # Setup: create user, group with same ID, role, and project; - # assign the group the role on the project. - - user_group_id = uuid.uuid4().hex - - user1 = {'id': user_group_id, 'name': uuid.uuid4().hex, - 'domain_id': CONF.identity.default_domain_id, } - self.identity_api.create_user(user_group_id, user1) - - group1 = {'id': user_group_id, 'name': uuid.uuid4().hex, - 'domain_id': CONF.identity.default_domain_id, } - self.identity_api.create_group(user_group_id, group1) - - role1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex} - self.assignment_api.create_role(role1['id'], role1) - - project1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex, - 'domain_id': CONF.identity.default_domain_id, } - self.assignment_api.create_project(project1['id'], project1) - - self.assignment_api.create_grant(role1['id'], - group_id=user_group_id, - project_id=project1['id']) - - # Check the roles, shouldn't be any since the user wasn't granted any. - roles = self.assignment_api.get_roles_for_user_and_project( - user_group_id, project1['id']) - - self.assertEqual([role1['id']], roles, - 'role for group is %s' % role1['id']) - def test_list_projects_for_alternate_domain(self): self.skipTest( 'N/A: LDAP does not support multiple domains')
keystone/tests/unit/common/test_ldap.py+169 −0 added@@ -0,0 +1,169 @@ +# 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. + +import ldap.dn + +from keystone.common import ldap as ks_ldap +from keystone import tests + + +class DnCompareTest(tests.BaseTestCase): + """Tests for the DN comparison functions in keystone.common.ldap.core.""" + + def test_prep(self): + # prep_case_insensitive returns the string with spaces at the front and + # end if it's already lowercase and no insignificant characters. + value = 'lowercase value' + self.assertEqual(value, ks_ldap.prep_case_insensitive(value)) + + def test_prep_lowercase(self): + # prep_case_insensitive returns the string with spaces at the front and + # end and lowercases the value. + value = 'UPPERCASE VALUE' + exp_value = value.lower() + self.assertEqual(exp_value, ks_ldap.prep_case_insensitive(value)) + + def test_prep_insignificant(self): + # prep_case_insensitive remove insignificant spaces. + value = 'before after' + exp_value = 'before after' + self.assertEqual(exp_value, ks_ldap.prep_case_insensitive(value)) + + def test_prep_insignificant_pre_post(self): + # prep_case_insensitive remove insignificant spaces. + value = ' value ' + exp_value = 'value' + self.assertEqual(exp_value, ks_ldap.prep_case_insensitive(value)) + + def test_ava_equal_same(self): + # is_ava_value_equal returns True if the two values are the same. + value = 'val1' + self.assertTrue(ks_ldap.is_ava_value_equal('cn', value, value)) + + def test_ava_equal_complex(self): + # is_ava_value_equal returns True if the two values are the same using + # a value that's got different capitalization and insignificant chars. + val1 = 'before after' + val2 = ' BEFORE afTer ' + self.assertTrue(ks_ldap.is_ava_value_equal('cn', val1, val2)) + + def test_ava_different(self): + # is_ava_value_equal returns False if the values aren't the same. + self.assertFalse(ks_ldap.is_ava_value_equal('cn', 'val1', 'val2')) + + def test_rdn_same(self): + # is_rdn_equal returns True if the two values are the same. + rdn = ldap.dn.str2dn('cn=val1')[0] + self.assertTrue(ks_ldap.is_rdn_equal(rdn, rdn)) + + def test_rdn_diff_length(self): + # is_rdn_equal returns False if the RDNs have a different number of + # AVAs. + rdn1 = ldap.dn.str2dn('cn=cn1')[0] + rdn2 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0] + self.assertFalse(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_multi_ava_same_order(self): + # is_rdn_equal returns True if the RDNs have the same number of AVAs + # and the values are the same. + rdn1 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0] + rdn2 = ldap.dn.str2dn('cn=CN1+ou=OU1')[0] + self.assertTrue(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_multi_ava_diff_order(self): + # is_rdn_equal returns True if the RDNs have the same number of AVAs + # and the values are the same, even if in a different order + rdn1 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0] + rdn2 = ldap.dn.str2dn('ou=OU1+cn=CN1')[0] + self.assertTrue(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_multi_ava_diff_type(self): + # is_rdn_equal returns False if the RDNs have the same number of AVAs + # and the attribute types are different. + rdn1 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0] + rdn2 = ldap.dn.str2dn('cn=cn1+sn=sn1')[0] + self.assertFalse(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_attr_type_case_diff(self): + # is_rdn_equal returns True for same RDNs even when attr type case is + # different. + rdn1 = ldap.dn.str2dn('cn=cn1')[0] + rdn2 = ldap.dn.str2dn('CN=cn1')[0] + self.assertTrue(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_attr_type_alias(self): + # is_rdn_equal returns False for same RDNs even when attr type alias is + # used. Note that this is a limitation since an LDAP server should + # consider them equal. + rdn1 = ldap.dn.str2dn('cn=cn1')[0] + rdn2 = ldap.dn.str2dn('2.5.4.3=cn1')[0] + self.assertFalse(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_dn_same(self): + # is_dn_equal returns True if the DNs are the same. + dn = 'cn=Babs Jansen,ou=OpenStack' + self.assertTrue(ks_ldap.is_dn_equal(dn, dn)) + + def test_dn_diff_length(self): + # is_dn_equal returns False if the DNs don't have the same number of + # RDNs + dn1 = 'cn=Babs Jansen,ou=OpenStack' + dn2 = 'cn=Babs Jansen,ou=OpenStack,dc=example.com' + self.assertFalse(ks_ldap.is_dn_equal(dn1, dn2)) + + def test_dn_equal_rdns(self): + # is_dn_equal returns True if the DNs have the same number of RDNs + # and each RDN is the same. + dn1 = 'cn=Babs Jansen,ou=OpenStack+cn=OpenSource' + dn2 = 'CN=Babs Jansen,cn=OpenSource+ou=OpenStack' + self.assertTrue(ks_ldap.is_dn_equal(dn1, dn2)) + + def test_dn_parsed_dns(self): + # is_dn_equal can also accept parsed DNs. + dn_str1 = ldap.dn.str2dn('cn=Babs Jansen,ou=OpenStack+cn=OpenSource') + dn_str2 = ldap.dn.str2dn('CN=Babs Jansen,cn=OpenSource+ou=OpenStack') + self.assertTrue(ks_ldap.is_dn_equal(dn_str1, dn_str2)) + + def test_startswith_under_child(self): + # dn_startswith returns True if descendant_dn is a child of dn. + child = 'cn=Babs Jansen,ou=OpenStack' + parent = 'ou=OpenStack' + self.assertTrue(ks_ldap.dn_startswith(child, parent)) + + def test_startswith_parent(self): + # dn_startswith returns False if descendant_dn is a parent of dn. + child = 'cn=Babs Jansen,ou=OpenStack' + parent = 'ou=OpenStack' + self.assertFalse(ks_ldap.dn_startswith(parent, child)) + + def test_startswith_same(self): + # dn_startswith returns False if DNs are the same. + dn = 'cn=Babs Jansen,ou=OpenStack' + self.assertFalse(ks_ldap.dn_startswith(dn, dn)) + + def test_startswith_not_parent(self): + # dn_startswith returns False if descendant_dn is not under the dn + child = 'cn=Babs Jansen,ou=OpenStack' + parent = 'dc=example.com' + self.assertFalse(ks_ldap.dn_startswith(child, parent)) + + def test_startswith_descendant(self): + # dn_startswith returns True if descendant_dn is a descendant of dn. + descendant = 'cn=Babs Jansen,ou=Keystone,ou=OpenStack,dc=example.com' + dn = 'ou=OpenStack,dc=example.com' + self.assertTrue(ks_ldap.dn_startswith(descendant, dn)) + + def test_startswith_parsed_dns(self): + # dn_startswith also accepts parsed DNs. + descendant = ldap.dn.str2dn('cn=Babs Jansen,ou=OpenStack') + dn = ldap.dn.str2dn('ou=OpenStack') + self.assertTrue(ks_ldap.dn_startswith(descendant, dn))
97dfd55ad1b4SQL fix for get_roles_for_user_and_project user=group ID
3 files changed · +91 −0
keystone/assignment/backends/sql.py+15 −0 modified@@ -79,6 +79,21 @@ def _get_metadata(self, user_id=None, tenant_id=None, session = sql.get_session() q = session.query(RoleAssignment) + + def _calc_assignment_type(): + # Figure out the assignment type we're checking for from the args. + if user_id: + if tenant_id: + return AssignmentType.USER_PROJECT + else: + return AssignmentType.USER_DOMAIN + else: + if tenant_id: + return AssignmentType.GROUP_PROJECT + else: + return AssignmentType.GROUP_DOMAIN + + q = q.filter_by(type=_calc_assignment_type()) q = q.filter_by(actor_id=user_id or group_id) q = q.filter_by(target_id=tenant_id or domain_id) refs = q.all()
keystone/tests/test_backend_ldap.py+39 −0 modified@@ -1250,6 +1250,45 @@ def test_multi_role_grant_by_user_group_on_project_domain(self): user1['id'], CONF.identity.default_domain_id) self.assertEqual(0, len(combined_role_list)) + def test_get_roles_for_user_and_project_user_group_same_id(self): + """When a user has the same ID as a group, + get_roles_for_user_and_project returns the roles for the group. + + Overriding this test for LDAP because it works differently. The role + for the group is returned. This is bug 1309228. + """ + + # Setup: create user, group with same ID, role, and project; + # assign the group the role on the project. + + user_group_id = uuid.uuid4().hex + + user1 = {'id': user_group_id, 'name': uuid.uuid4().hex, + 'domain_id': CONF.identity.default_domain_id, } + self.identity_api.create_user(user_group_id, user1) + + group1 = {'id': user_group_id, 'name': uuid.uuid4().hex, + 'domain_id': CONF.identity.default_domain_id, } + self.identity_api.create_group(user_group_id, group1) + + role1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex} + self.assignment_api.create_role(role1['id'], role1) + + project1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex, + 'domain_id': CONF.identity.default_domain_id, } + self.assignment_api.create_project(project1['id'], project1) + + self.assignment_api.create_grant(role1['id'], + group_id=user_group_id, + project_id=project1['id']) + + # Check the roles, shouldn't be any since the user wasn't granted any. + roles = self.assignment_api.get_roles_for_user_and_project( + user_group_id, project1['id']) + + self.assertEqual([role1['id']], roles, + 'role for group is %s' % role1['id']) + def test_list_projects_for_alternate_domain(self): self.skipTest( 'N/A: LDAP does not support multiple domains')
keystone/tests/test_backend.py+37 −0 modified@@ -1405,6 +1405,43 @@ def test_multi_group_grants_on_project_domain(self): self.assertIn(role_list[1]['id'], combined_role_list) self.assertIn(role_list[2]['id'], combined_role_list) + def test_get_roles_for_user_and_project_user_group_same_id(self): + """When a user has the same ID as a group, + get_roles_for_user_and_project returns only the roles for the user and + not the group. + + """ + + # Setup: create user, group with same ID, role, and project; + # assign the group the role on the project. + + user_group_id = uuid.uuid4().hex + + user1 = {'id': user_group_id, 'name': uuid.uuid4().hex, + 'domain_id': DEFAULT_DOMAIN_ID, } + self.identity_api.create_user(user_group_id, user1) + + group1 = {'id': user_group_id, 'name': uuid.uuid4().hex, + 'domain_id': DEFAULT_DOMAIN_ID, } + self.identity_api.create_group(user_group_id, group1) + + role1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex} + self.assignment_api.create_role(role1['id'], role1) + + project1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex, + 'domain_id': DEFAULT_DOMAIN_ID, } + self.assignment_api.create_project(project1['id'], project1) + + self.assignment_api.create_grant(role1['id'], + group_id=user_group_id, + project_id=project1['id']) + + # Check the roles, shouldn't be any since the user wasn't granted any. + roles = self.assignment_api.get_roles_for_user_and_project( + user_group_id, project1['id']) + + self.assertEqual([], roles, 'role for group is %s' % role1['id']) + def test_delete_role_with_user_and_group_grants(self): role1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex} self.assignment_api.create_role(role1['id'], role1)
786af9829c53SQL and LDAP fixes for get_roles_for_user_and_project user=group ID
7 files changed · +378 −9
keystone/assignment/backends/ldap.py+3 −8 modified@@ -88,24 +88,19 @@ def _get_metadata(self, user_id=None, tenant_id=None, def _get_roles_for_just_user_and_project(user_id, tenant_id): self.get_project(tenant_id) + user_dn = self.user._id_to_dn(user_id) return [self.role._dn_to_id(a.role_dn) for a in self.role.get_role_assignments (self.project._id_to_dn(tenant_id)) - if self.user._dn_to_id(a.user_dn) == user_id] + if common_ldap.is_dn_equal(a.user_dn, user_dn)] def _get_roles_for_group_and_project(group_id, project_id): self.get_project(project_id) group_dn = self.group._id_to_dn(group_id) - # NOTE(marcos-fermin-lobo): In Active Directory, for functions - # such as "self.role.get_role_assignments", it returns - # the key "CN" or "OU" in uppercase. - # The group_dn var has "CN" and "OU" in lowercase. - # For this reason, it is necessary to use the "upper()" - # function so both are consistent. return [self.role._dn_to_id(a.role_dn) for a in self.role.get_role_assignments (self.project._id_to_dn(project_id)) - if a.user_dn.upper() == group_dn.upper()] + if common_ldap.is_dn_equal(a.user_dn, group_dn)] if domain_id is not None: msg = _('Domain metadata not supported by LDAP')
keystone/assignment/backends/sql.py+15 −0 modified@@ -86,6 +86,21 @@ def _get_metadata(self, user_id=None, tenant_id=None, session = sql.get_session() q = session.query(RoleAssignment) + + def _calc_assignment_type(): + # Figure out the assignment type we're checking for from the args. + if user_id: + if tenant_id: + return AssignmentType.USER_PROJECT + else: + return AssignmentType.USER_DOMAIN + else: + if tenant_id: + return AssignmentType.GROUP_PROJECT + else: + return AssignmentType.GROUP_DOMAIN + + q = q.filter_by(type=_calc_assignment_type()) q = q.filter_by(actor_id=user_id or group_id) q = q.filter_by(target_id=tenant_id or domain_id) refs = q.all()
keystone/common/ldap/core.py+110 −0 modified@@ -13,6 +13,7 @@ # under the License. import os.path +import re import ldap import ldap.filter @@ -101,6 +102,115 @@ def ldap_scope(scope): 'options': ', '.join(LDAP_SCOPES.keys())}) +def prep_case_insensitive(value): + """Prepare a string for case-insensitive comparison. + + This is defined in RFC4518. For simplicity, all this function does is + lowercase all the characters, strip leading and trailing whitespace, + and compress sequences of spaces to a single space. + """ + value = re.sub(r'\s+', ' ', value.strip().lower()) + return value + + +def is_ava_value_equal(attribute_type, val1, val2): + """Returns True if and only if the AVAs are equal. + + When comparing AVAs, the equality matching rule for the attribute type + should be taken into consideration. For simplicity, this implementation + does a case-insensitive comparison. + + Note that this function uses prep_case_insenstive so the limitations of + that function apply here. + + """ + + return prep_case_insensitive(val1) == prep_case_insensitive(val2) + + +def is_rdn_equal(rdn1, rdn2): + """Returns True if and only if the RDNs are equal. + + * RDNs must have the same number of AVAs. + * Each AVA of the RDNs must be the equal for the same attribute type. The + order isn't significant. Note that an attribute type will only be in one + AVA in an RDN, otherwise the DN wouldn't be valid. + * Attribute types aren't case sensitive. Note that attribute type + comparison is more complicated than implemented. This function only + compares case-insentive. The code should handle multiple names for an + attribute type (e.g., cn, commonName, and 2.5.4.3 are the same). + + Note that this function uses is_ava_value_equal to compare AVAs so the + limitations of that function apply here. + + """ + + if len(rdn1) != len(rdn2): + return False + + for attr_type_1, val1, dummy in rdn1: + found = False + for attr_type_2, val2, dummy in rdn2: + if attr_type_1.lower() != attr_type_2.lower(): + continue + + found = True + if not is_ava_value_equal(attr_type_1, val1, val2): + return False + break + if not found: + return False + + return True + + +def is_dn_equal(dn1, dn2): + """Returns True if and only if the DNs are equal. + + Two DNs are equal if they've got the same number of RDNs and if the RDNs + are the same at each position. See RFC4517. + + Note that this function uses is_rdn_equal to compare RDNs so the + limitations of that function apply here. + + :param dn1: Either a string DN or a DN parsed by ldap.dn.str2dn. + :param dn2: Either a string DN or a DN parsed by ldap.dn.str2dn. + + """ + + if not isinstance(dn1, list): + dn1 = ldap.dn.str2dn(dn1) + if not isinstance(dn2, list): + dn2 = ldap.dn.str2dn(dn2) + + if len(dn1) != len(dn2): + return False + + for rdn1, rdn2 in zip(dn1, dn2): + if not is_rdn_equal(rdn1, rdn2): + return False + return True + + +def dn_startswith(descendant_dn, dn): + """Returns True if and only if the descendant_dn is under the dn. + + :param descendant_dn: Either a string DN or a DN parsed by ldap.dn.str2dn. + :param dn: Either a string DN or a DN parsed by ldap.dn.str2dn. + + """ + + if not isinstance(descendant_dn, list): + descendant_dn = ldap.dn.str2dn(descendant_dn) + if not isinstance(dn, list): + dn = ldap.dn.str2dn(dn) + + if len(descendant_dn) <= len(dn): + return False + + return is_dn_equal(descendant_dn[len(dn):], dn) + + _HANDLERS = {}
keystone/tests/fakeldap.py+16 −1 modified@@ -51,6 +51,19 @@ def _process_attr(attr_name, value_or_values): def normalize_dn(dn): # Capitalize the attribute names as an LDAP server might. + + # NOTE(blk-u): Special case for this tested value, used with + # test_user_id_comma. The call to str2dn here isn't always correct + # here, because `dn` is escaped for an LDAP filter. str2dn() normally + # works only because there's no special characters in `dn`. + if dn == 'cn=Doe\\5c, John,ou=Users,cn=example,cn=com': + return 'CN=Doe\\, John,OU=Users,CN=example,CN=com' + + # NOTE(blk-u): Another special case for this tested value. When a + # roleOccupant has an escaped comma, it gets converted to \2C. + if dn == 'cn=Doe\\, John,ou=Users,cn=example,cn=com': + return 'CN=Doe\\2C John,OU=Users,CN=example,CN=com' + dn = ldap.dn.str2dn(dn) norm = [] for part in dn: @@ -118,7 +131,9 @@ def _match(key, value, attrs): str_sids = [str(x) for x in attrs[key]] return str(value) in str_sids if key != 'objectclass': - return _process_attr(key, value)[0] in attrs[key] + check_value = _process_attr(key, value)[0] + norm_values = list(_process_attr(key, x)[0] for x in attrs[key]) + return check_value in norm_values # it is an objectclass check, so check subclasses values = _subs(value) for v in values:
keystone/tests/test_backend_ldap.py+28 −0 modified@@ -546,6 +546,34 @@ def test_new_arbitrary_attributes_are_returned_from_update_user(self): def test_updated_arbitrary_attributes_are_returned_from_update_user(self): self.skipTest("Using arbitrary attributes doesn't work under LDAP") + def test_user_id_comma_grants(self): + """Even if the user has a , in their ID, can get user and group grants. + """ + + # Create a user with a , in their ID + # NOTE(blk-u): the DN for this user is hard-coded in fakeldap! + user_id = u'Doe, John' + user = { + 'id': user_id, + 'name': self.getUniqueString(), + 'password': self.getUniqueString(), + 'domain_id': CONF.identity.default_domain_id, + } + self.identity_api.create_user(user_id, user) + + # Grant the user a role on a project. + + role_id = 'member' + project_id = self.tenant_baz['id'] + + self.assignment_api.create_grant(role_id, user_id=user_id, + project_id=project_id) + + role_ref = self.assignment_api.get_grant(role_id, user_id=user_id, + project_id=project_id) + + self.assertEqual(role_id, role_ref['id']) + class LDAPIdentity(BaseLDAPIdentity, tests.TestCase): def setUp(self):
keystone/tests/test_backend.py+37 −0 modified@@ -1377,6 +1377,43 @@ def test_multi_group_grants_on_project_domain(self): self.assertIn(role_list[1]['id'], combined_role_list) self.assertIn(role_list[2]['id'], combined_role_list) + def test_get_roles_for_user_and_project_user_group_same_id(self): + """When a user has the same ID as a group, + get_roles_for_user_and_project returns only the roles for the user and + not the group. + + """ + + # Setup: create user, group with same ID, role, and project; + # assign the group the role on the project. + + user_group_id = uuid.uuid4().hex + + user1 = {'id': user_group_id, 'name': uuid.uuid4().hex, + 'domain_id': DEFAULT_DOMAIN_ID, } + self.identity_api.create_user(user_group_id, user1) + + group1 = {'id': user_group_id, 'name': uuid.uuid4().hex, + 'domain_id': DEFAULT_DOMAIN_ID, } + self.identity_api.create_group(user_group_id, group1) + + role1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex} + self.assignment_api.create_role(role1['id'], role1) + + project1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex, + 'domain_id': DEFAULT_DOMAIN_ID, } + self.assignment_api.create_project(project1['id'], project1) + + self.assignment_api.create_grant(role1['id'], + group_id=user_group_id, + project_id=project1['id']) + + # Check the roles, shouldn't be any since the user wasn't granted any. + roles = self.assignment_api.get_roles_for_user_and_project( + user_group_id, project1['id']) + + self.assertEqual([], roles, 'role for group is %s' % role1['id']) + def test_delete_role_with_user_and_group_grants(self): role1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex} self.assignment_api.create_role(role1['id'], role1)
keystone/tests/unit/common/test_ldap.py+169 −0 added@@ -0,0 +1,169 @@ +# 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. + +import ldap.dn + +from keystone.common import ldap as ks_ldap +from keystone import tests + + +class DnCompareTest(tests.BaseTestCase): + """Tests for the DN comparison functions in keystone.common.ldap.core.""" + + def test_prep(self): + # prep_case_insensitive returns the string with spaces at the front and + # end if it's already lowercase and no insignificant characters. + value = 'lowercase value' + self.assertEqual(value, ks_ldap.prep_case_insensitive(value)) + + def test_prep_lowercase(self): + # prep_case_insensitive returns the string with spaces at the front and + # end and lowercases the value. + value = 'UPPERCASE VALUE' + exp_value = value.lower() + self.assertEqual(exp_value, ks_ldap.prep_case_insensitive(value)) + + def test_prep_insignificant(self): + # prep_case_insensitive remove insignificant spaces. + value = 'before after' + exp_value = 'before after' + self.assertEqual(exp_value, ks_ldap.prep_case_insensitive(value)) + + def test_prep_insignificant_pre_post(self): + # prep_case_insensitive remove insignificant spaces. + value = ' value ' + exp_value = 'value' + self.assertEqual(exp_value, ks_ldap.prep_case_insensitive(value)) + + def test_ava_equal_same(self): + # is_ava_value_equal returns True if the two values are the same. + value = 'val1' + self.assertTrue(ks_ldap.is_ava_value_equal('cn', value, value)) + + def test_ava_equal_complex(self): + # is_ava_value_equal returns True if the two values are the same using + # a value that's got different capitalization and insignificant chars. + val1 = 'before after' + val2 = ' BEFORE afTer ' + self.assertTrue(ks_ldap.is_ava_value_equal('cn', val1, val2)) + + def test_ava_different(self): + # is_ava_value_equal returns False if the values aren't the same. + self.assertFalse(ks_ldap.is_ava_value_equal('cn', 'val1', 'val2')) + + def test_rdn_same(self): + # is_rdn_equal returns True if the two values are the same. + rdn = ldap.dn.str2dn('cn=val1')[0] + self.assertTrue(ks_ldap.is_rdn_equal(rdn, rdn)) + + def test_rdn_diff_length(self): + # is_rdn_equal returns False if the RDNs have a different number of + # AVAs. + rdn1 = ldap.dn.str2dn('cn=cn1')[0] + rdn2 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0] + self.assertFalse(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_multi_ava_same_order(self): + # is_rdn_equal returns True if the RDNs have the same number of AVAs + # and the values are the same. + rdn1 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0] + rdn2 = ldap.dn.str2dn('cn=CN1+ou=OU1')[0] + self.assertTrue(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_multi_ava_diff_order(self): + # is_rdn_equal returns True if the RDNs have the same number of AVAs + # and the values are the same, even if in a different order + rdn1 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0] + rdn2 = ldap.dn.str2dn('ou=OU1+cn=CN1')[0] + self.assertTrue(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_multi_ava_diff_type(self): + # is_rdn_equal returns False if the RDNs have the same number of AVAs + # and the attribute types are different. + rdn1 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0] + rdn2 = ldap.dn.str2dn('cn=cn1+sn=sn1')[0] + self.assertFalse(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_attr_type_case_diff(self): + # is_rdn_equal returns True for same RDNs even when attr type case is + # different. + rdn1 = ldap.dn.str2dn('cn=cn1')[0] + rdn2 = ldap.dn.str2dn('CN=cn1')[0] + self.assertTrue(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_rdn_attr_type_alias(self): + # is_rdn_equal returns False for same RDNs even when attr type alias is + # used. Note that this is a limitation since an LDAP server should + # consider them equal. + rdn1 = ldap.dn.str2dn('cn=cn1')[0] + rdn2 = ldap.dn.str2dn('2.5.4.3=cn1')[0] + self.assertFalse(ks_ldap.is_rdn_equal(rdn1, rdn2)) + + def test_dn_same(self): + # is_dn_equal returns True if the DNs are the same. + dn = 'cn=Babs Jansen,ou=OpenStack' + self.assertTrue(ks_ldap.is_dn_equal(dn, dn)) + + def test_dn_diff_length(self): + # is_dn_equal returns False if the DNs don't have the same number of + # RDNs + dn1 = 'cn=Babs Jansen,ou=OpenStack' + dn2 = 'cn=Babs Jansen,ou=OpenStack,dc=example.com' + self.assertFalse(ks_ldap.is_dn_equal(dn1, dn2)) + + def test_dn_equal_rdns(self): + # is_dn_equal returns True if the DNs have the same number of RDNs + # and each RDN is the same. + dn1 = 'cn=Babs Jansen,ou=OpenStack+cn=OpenSource' + dn2 = 'CN=Babs Jansen,cn=OpenSource+ou=OpenStack' + self.assertTrue(ks_ldap.is_dn_equal(dn1, dn2)) + + def test_dn_parsed_dns(self): + # is_dn_equal can also accept parsed DNs. + dn_str1 = ldap.dn.str2dn('cn=Babs Jansen,ou=OpenStack+cn=OpenSource') + dn_str2 = ldap.dn.str2dn('CN=Babs Jansen,cn=OpenSource+ou=OpenStack') + self.assertTrue(ks_ldap.is_dn_equal(dn_str1, dn_str2)) + + def test_startswith_under_child(self): + # dn_startswith returns True if descendant_dn is a child of dn. + child = 'cn=Babs Jansen,ou=OpenStack' + parent = 'ou=OpenStack' + self.assertTrue(ks_ldap.dn_startswith(child, parent)) + + def test_startswith_parent(self): + # dn_startswith returns False if descendant_dn is a parent of dn. + child = 'cn=Babs Jansen,ou=OpenStack' + parent = 'ou=OpenStack' + self.assertFalse(ks_ldap.dn_startswith(parent, child)) + + def test_startswith_same(self): + # dn_startswith returns False if DNs are the same. + dn = 'cn=Babs Jansen,ou=OpenStack' + self.assertFalse(ks_ldap.dn_startswith(dn, dn)) + + def test_startswith_not_parent(self): + # dn_startswith returns False if descendant_dn is not under the dn + child = 'cn=Babs Jansen,ou=OpenStack' + parent = 'dc=example.com' + self.assertFalse(ks_ldap.dn_startswith(child, parent)) + + def test_startswith_descendant(self): + # dn_startswith returns True if descendant_dn is a descendant of dn. + descendant = 'cn=Babs Jansen,ou=Keystone,ou=OpenStack,dc=example.com' + dn = 'ou=OpenStack,dc=example.com' + self.assertTrue(ks_ldap.dn_startswith(descendant, dn)) + + def test_startswith_parsed_dns(self): + # dn_startswith also accepts parsed DNs. + descendant = ldap.dn.str2dn('cn=Babs Jansen,ou=OpenStack') + dn = ldap.dn.str2dn('ou=OpenStack') + self.assertTrue(ks_ldap.dn_startswith(descendant, dn))
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- www.openwall.com/lists/oss-security/2014/05/21/3nvdMailing ListPatchThird Party AdvisoryWEB
- review.openstack.orgnvdPatchThird Party Advisory
- bugs.launchpad.net/keystone/+bug/1309228nvdExploitIssue TrackingThird Party AdvisoryWEB
- github.com/advisories/GHSA-c4p9-87h3-7vr4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2014-0204ghsaADVISORY
- github.com/openstack/keystone/commit/729dcad7384ba66ee7494154969cdd7ae90d86eeghsaWEB
- github.com/openstack/keystone/commit/786af9829c5329a982e3451f77afebbfb21850bdghsaWEB
- github.com/openstack/keystone/commit/97dfd55ad1b40365754dcbfce856f7ffae280a44ghsaWEB
- github.com/openstack/keystone/commit/f0eee2f3b48dd0cffb9f75e396da2d914925cba5ghsaWEB
- review.openstack.orgghsaWEB
News mentions
0No linked articles in our index yet.