diff --git a/0085-CVE-2026-11770-pre-auth-LDAP-filter-injection-in-Cle.patch b/0085-CVE-2026-11770-pre-auth-LDAP-filter-injection-in-Cle.patch new file mode 100644 index 0000000..419f62e --- /dev/null +++ b/0085-CVE-2026-11770-pre-auth-LDAP-filter-injection-in-Cle.patch @@ -0,0 +1,874 @@ +From 48e2b5f896ad021d9cda5cff409050bb37e2bf17 Mon Sep 17 00:00:00 2001 +From: progier389 +Date: Thu, 18 Jun 2026 19:11:31 +0200 +Subject: [PATCH] CVE-2026-11770 - pre-auth LDAP filter injection in CleanRUV + + - Add authentication checks for all the replication extended operations + by: + - checking directly that binddn is accepted by a replica for the cleanruv + related and the start replication session extended operations: + REPL_CLEANRUV_OID + REPL_ABORT_CLEANRUV_OID + REPL_CLEANRUV_GET_MAXCSN_OID + REPL_CLEANRUV_CHECK_STATUS_OID + REPL_START_NSDS50_REPLICATION_REQUEST_OID + REPL_START_NSDS90_REPLICATION_REQUEST_OID + - Checking that an active replication session is associated with the + connection for the other session related operations: + REPL_END_NSDS50_REPLICATION_REQUEST_OID + REPL_NSDS50_REPLICATION_ENTRY_REQUEST_OID + REPL_NSDS71_REPLICATION_ENTRY_REQUEST_OID + Note about REPL_END_NSDS50_REPLICATION_REQUEST_OID: + if there is no replication session, the operation + does nothing else than sending success response + - Implement LDAP injection protection for REPL_CLEANRUV_CHECK_STATUS_OID operation + - Add comprehensive test suite (38+ test cases) + - Fix isdigit() undefined behavior for non-ASCII characters + - Designed a slower but safer way to iterate on the replicas to + avoid deadlocks within the callbacks + + Security impact: + - Prevents LDAP injection via malformed CleanAllRUV filters + - Blocks unauthorized access to cleanruv related extended + operations + - Blocks anonymous access to replication session related + extended operations + + Test coverage: + - All replication extended operation OIDs + - Anonymous, unauthorized, and authorized access + - LDAP injection attempts + - Dynamic authorization revocation + +Assisted-by: Claude Ai +--- + .../test_cleanruv_extop_security.py | 452 ++++++++++++++++++ + ldap/servers/plugins/replication/repl5.h | 2 + + .../replication/repl5_replica_config.c | 72 +++ + .../servers/plugins/replication/repl5_total.c | 13 + + ldap/servers/plugins/replication/repl_extop.c | 167 +++++++ + 5 files changed, 706 insertions(+) + create mode 100644 dirsrvtests/tests/suites/replication/test_cleanruv_extop_security.py + +diff --git a/dirsrvtests/tests/suites/replication/test_cleanruv_extop_security.py b/dirsrvtests/tests/suites/replication/test_cleanruv_extop_security.py +new file mode 100644 +index 000000000..041347861 +--- /dev/null ++++ b/dirsrvtests/tests/suites/replication/test_cleanruv_extop_security.py +@@ -0,0 +1,452 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++# ++""" ++Test the security of CleanAllRUV extended operations. ++ ++This test suite verifies that: ++1. Anonymous connections cannot send CleanAllRUV extended operations ++2. Unauthorized users cannot send CleanAllRUV extended operations ++3. LDAP injection via malformed filter payloads is blocked ++4. Only authorized replication bind DNs can use these operations ++5. All 4 replication extended operations enforce security checks: ++ - CleanAllRUV (2.16.840.1.113730.3.6.5) ++ - Abort CleanAllRUV (2.16.840.1.113730.3.6.6) ++ - Get MaxCSN (2.16.840.1.113730.3.6.7) ++ - Check Status (2.16.840.1.113730.3.6.8) ++6. Authorization is properly revoked when user is removed from replication group ++""" ++ ++import os ++import time ++import ldap ++import pytest ++from contextlib import contextmanager ++from lib389._constants import DEFAULT_SUFFIX, PASSWORD ++from lib389.idm.group import Groups ++from lib389.idm.user import UserAccounts ++from pyasn1.codec.ber import encoder ++from pyasn1.type import univ ++from test389.topologies import topology_m2 ++ ++pytestmark = pytest.mark.tier1 ++ ++import logging ++log = logging.getLogger(__name__) ++ ++DEBUGGING = os.getenv('DEBUGGING', default=False) ++ ++# Extended operation OIDs ++REPL_CLEANRUV_CHECK_STATUS_OID = "2.16.840.1.113730.3.6.8" ++REPL_CLEANRUV_GET_MAXCSN_OID = "2.16.840.1.113730.3.6.7" ++REPL_CLEANRUV_OID = "2.16.840.1.113730.3.6.5" ++REPL_ABORT_CLEANRUV_OID = "2.16.840.1.113730.3.6.6" ++REPL_SESSION_END_OID = "2.16.840.1.113730.3.5.12" ++ ++ ++REPL_START_NSDS50_REPLICATION_REQUEST_OID = "2.16.840.1.113730.3.5.3" ++REPL_END_NSDS50_REPLICATION_REQUEST_OID = "2.16.840.1.113730.3.5.5" ++REPL_NSDS50_REPLICATION_ENTRY_REQUEST_OID = "2.16.840.1.113730.3.5.6" ++REPL_NSDS50_REPLICATION_RESPONSE_OID = "2.16.840.1.113730.3.5.4" ++REPL_NSDS50_UPDATE_INFO_CONTROL_OID = "2.16.840.1.113730.3.4.13" ++REPL_NSDS50_INCREMENTAL_PROTOCOL_OID = "2.16.840.1.113730.3.6.1" ++REPL_NSDS50_TOTAL_PROTOCOL_OID = "2.16.840.1.113730.3.6.2" ++REPL_NSDS71_INCREMENTAL_PROTOCOL_OID = "2.16.840.1.113730.3.6.4" ++REPL_NSDS71_TOTAL_PROTOCOL_OID = "2.16.840.1.113730.3.6.3" ++REPL_NSDS71_REPLICATION_ENTRY_REQUEST_OID = "2.16.840.1.113730.3.5.9" ++REPL_START_NSDS90_REPLICATION_REQUEST_OID = "2.16.840.1.113730.3.5.12" ++REPL_NSDS90_REPLICATION_RESPONSE_OID = "2.16.840.1.113730.3.5.13" ++REPL_CLEANRUV_OID = "2.16.840.1.113730.3.6.5" ++REPL_ABORT_CLEANRUV_OID = "2.16.840.1.113730.3.6.6" ++REPL_CLEANRUV_GET_MAXCSN_OID = "2.16.840.1.113730.3.6.7" ++REPL_CLEANRUV_CHECK_STATUS_OID = "2.16.840.1.113730.3.6.8" ++REPL_ABORT_SESSION_OID = "2.16.840.1.113730.3.6.9" ++ ++# Delay (in seconds) need to resynchronized the group cache ++REPL_GROUP_SYNC_DELAY = 30.0 ++ ++# Bind DN types for parametrization ++BIND_ANONYMOUS = "anonymous" ++BIND_UNAUTHORIZED = "unauthorized" ++BIND_AUTHORIZED = "authorized" ++ ++ ++class AccessLogWatcher: ++ """ ++ Helper class allowing to wait until a new specific extended operation ++ is completed. ++ """ ++ ++ @staticmethod ++ def countOp(inst, oid): ++ """ ++ Count the completed extended operations having specified OID. ++ """ ++ import re ++ nbops = 0 ++ re1 = re.compile(fr'^.* conn=(?P[\d+])\sop=(?P[\d+])\sEXT\soid="{oid}".*') ++ re2 = re.compile(r'^.* conn=(?P[\d+])\sop=(?P[\d+])\sRESULT\s' + ++ r'err=(?P[\d+])\stag=120\s.*') ++ saved_result = {} ++ nblines = 0 ++ with open(inst.ds_paths.access_log, 'r') as fd: ++ for line in fd: ++ nblines += 1 ++ result = re1.match(line) ++ if result: ++ saved_result = result.groupdict() ++ continue ++ if saved_result: ++ result = re2.match(line) ++ if result: ++ result = result.groupdict() ++ if (saved_result['op'] == result['op'] and ++ saved_result['conn'] == result['conn']): ++ saved_result = {} ++ nbops += 1 ++ log.info(f'AccessLogWatcher.countOp: nbops={nbops}') ++ log.info(f'AccessLogWatcher.countOp: nblines={nblines}') ++ return nbops ++ ++ def __init__(self, inst, oid): ++ self._inst = inst ++ self._oid = oid ++ self._nbops = AccessLogWatcher.countOp(inst, oid) ++ ++ def wait(self, timeout=30): ++ """Wait for a new extended operation with the specified OID to complete.""" ++ start_time = time.time() ++ while time.time() - start_time < timeout: ++ if AccessLogWatcher.countOp(self._inst, self._oid) > self._nbops: ++ return ++ time.sleep(1.0) ++ assert False, f"Failed to complete {self._oid} extended operation before timeout of {timeout}s" ++ ++ ++def create_cleanruv_payload(filter_string): ++ """ ++ Create a BER-encoded payload for CleanAllRUV extended operations. ++ ++ The payload is encoded as: { string } ++ where string is an LDAP filter. ++ ++ Args: ++ filter_string: The filter string to encode ++ ++ Returns: ++ bytes: BER-encoded payload ++ """ ++ sequence = univ.Sequence() ++ sequence.setComponentByPosition(0, univ.OctetString(filter_string)) ++ return encoder.encode(sequence) ++ ++ ++@contextmanager ++def open_conn(inst, binddn=None, passwd=None): ++ """ ++ Open ldap connection and bind. ++ """ ++ conn = ldap.initialize(f"ldap://{inst.host}:{inst.port}") ++ try: ++ if binddn is not None: ++ conn.simple_bind_s(binddn, passwd) ++ yield conn ++ finally: ++ conn.unbind() ++ ++ ++@pytest.fixture(scope="module") ++def init_bind_user(topology_m2, request): ++ """Create test users 1 authorized and 1 not authorized and return the credentials map.""" ++ supplier1 = topology_m2.ms["supplier1"] ++ ++ users_accounts = UserAccounts(supplier1, DEFAULT_SUFFIX) ++ users = [] ++ ++ def cleanup(): ++ for u in users: ++ u.delete() ++ for inst in topology_m2: ++ inst.config.set('nsslapd-accesslog-logbuffering', 'on') ++ ++ for idx in range(2): ++ uid = f'bind_user{idx}' ++ if not users_accounts.exists(uid): ++ user = users_accounts.create(properties={ ++ 'uid': uid, ++ 'cn': uid, ++ 'sn': f'User{idx}', ++ 'givenname': 'Bind', ++ 'userpassword': PASSWORD, ++ 'uidNumber': str(10000+idx), ++ 'gidNumber': str(10000+idx), ++ 'homeDirectory': f'/home/{uid}' ++ }) ++ else: ++ user = users_accounts.get(uid) ++ users.append(user) ++ if not DEBUGGING: ++ request.addfinalizer(cleanup) ++ ++ # Add users[1] in replication group ++ groups = Groups(supplier1, basedn=DEFAULT_SUFFIX, rdn=None) ++ repl_group = groups.get(dn=f'cn=replication_managers,{DEFAULT_SUFFIX}') ++ repl_group.ensure_member(users[1].dn) ++ # Ensure that group cache get updated ++ time.sleep(REPL_GROUP_SYNC_DELAY) ++ # Disable access log buffering ++ for inst in topology_m2: ++ inst.config.set('nsslapd-accesslog-logbuffering', 'off') ++ ++ return { ++ BIND_ANONYMOUS: ( None, None ), ++ BIND_UNAUTHORIZED: ( users[0].dn, PASSWORD ), ++ BIND_AUTHORIZED: ( users[1].dn, PASSWORD ), ++ } ++ ++def check_extop(inst, creds, extreq, bind_type, filter, expected_exception, description): ++ """ ++ Open a new connection, bind as the bind_type user. ++ Perform the extended operation defined by extreq and described by filter ++ and description. ++ Then check that the expected_exception is got. ++ """ ++ binddn = creds[bind_type][0] ++ bindpw = creds[bind_type][1] ++ log.info(f"Testing: {description}") ++ log.info(f" Bind type: {bind_type} - Bind DN: {binddn}") ++ log.info(f" Filter: {filter}") ++ log.info(f" Expected: {expected_exception}") ++ ++ # Create connection and test ++ with open_conn(inst, binddn, bindpw) as conn: ++ log.info(f'Open new connection and bind as {bind_type} {binddn}') ++ # Attempt the extended operation ++ try: ++ result = conn.extop_s(extreq) ++ log.info(f"Extended operation succeeded. result is {result}") ++ assert expected_exception is None ++ except ldap.LDAPError as ex: ++ log.info(f"Extended operation failed with {ex}") ++ log.info(f"Expected exception is {expected_exception}") ++ assert expected_exception is not None ++ assert isinstance(ex, expected_exception) ++ ++ ++ ++@pytest.mark.parametrize("bind_type,filter_bytes,expected_exception,test_description", [ ++ # Anonymous bind tests ++ (BIND_ANONYMOUS, b"(nsds5ReplicaCleanRUV=5:1234567890:test)", ldap.INSUFFICIENT_ACCESS, ++ "Anonymous bind with valid filter for checking cleanruv task"), ++ (BIND_ANONYMOUS, b"(nsds5ReplicaAbortCleanRUV=5:dc=example,dc=com)", ldap.INSUFFICIENT_ACCESS, ++ "Anonymous bind with valid filter for checking abortion"), ++ (BIND_ANONYMOUS, b"(nsslapd-localhost=*)", ldap.INSUFFICIENT_ACCESS, ++ "Anonymous bind with injection attempt"), ++ ++ # Unauthorized user tests ++ (BIND_UNAUTHORIZED, b"(nsds5ReplicaCleanRUV=5:1234567890:test)", ldap.INSUFFICIENT_ACCESS, ++ "Unauthorized user with valid filter for checking cleanruv task"), ++ (BIND_UNAUTHORIZED, b"(nsds5ReplicaAbortCleanRUV=5:dc=example,dc=com)", ldap.INSUFFICIENT_ACCESS, ++ "Unauthorized user with valid filter for checking abortion"), ++ (BIND_UNAUTHORIZED, b"(nsslapd-localhost=*)", ldap.INSUFFICIENT_ACCESS, ++ "Unauthorized user with injection attempt"), ++ ++ # Authorized user with malicious filters - should reject filter, not auth ++ (BIND_AUTHORIZED, b"(nsslapd-localhost=*)", (ldap.OPERATIONS_ERROR, ldap.INSUFFICIENT_ACCESS), ++ "Injection: Server config leak"), ++ (BIND_AUTHORIZED, b"(userPassword=*)", (ldap.OPERATIONS_ERROR, ldap.INSUFFICIENT_ACCESS), ++ "Injection: Password leak attempt"), ++ (BIND_AUTHORIZED, b"(objectClass=*)", (ldap.OPERATIONS_ERROR, ldap.INSUFFICIENT_ACCESS), ++ "Injection: Overly broad search"), ++ (BIND_AUTHORIZED, b"(cn=*)", ldap.OPERATIONS_ERROR, ++ "Injection: General info leak"), ++ (BIND_AUTHORIZED, b"(&(nsds5ReplicaCleanRUV=5:*)(cn=*))", ldap.OPERATIONS_ERROR, ++ "Injection: Compound filter"), ++ (BIND_AUTHORIZED, b"(|(nsds5ReplicaCleanRUV=5:*)(userPassword=*))", ldap.OPERATIONS_ERROR, ++ "Injection: OR filter"), ++ (BIND_AUTHORIZED, b"(nsds5ReplicaCleanRUV=5:*)(cn=*)", None, ++ "Injection: Multiple filters"), # The second filter is ignored ++ (BIND_AUTHORIZED, b"", ldap.OPERATIONS_ERROR, ++ "Injection: Empty filter"), ++ (BIND_AUTHORIZED, b"invalid filter", ldap.OPERATIONS_ERROR, ++ "Injection: Invalid LDAP filter"), ++ (BIND_AUTHORIZED, b"(nsds5ReplicaCleanRUV=", (ldap.OPERATIONS_ERROR, ldap.INSUFFICIENT_ACCESS), ++ "Injection: Incomplete filter"), ++ (BIND_AUTHORIZED, b"(nsds5ReplicaCleanRUV=5:1234567890:no:0:dc=example,dc=com)", None, ++ "Valid filter for checking cleanruv task"), ++ (BIND_AUTHORIZED, b"(nsds5ReplicaAbortCleanRUV=5:dc=example,dc=com)", None, ++ "Valid filter for checking abortion"), ++]) ++def test_cleanruv_extop_security(topology_m2, init_bind_user, bind_type, filter_bytes, expected_exception, test_description): ++ """Parametrized test for CleanAllRUV extended operation security. ++ ++ Tests various combinations of bind DN types, filter payloads, and expected results. ++ ++ :id: a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d ++ :parametrized: yes ++ :setup: Replication setup with two suppliers ++ :steps: ++ 1. Connect with specified bind DN type ++ 2. Send REPL_CLEANRUV_CHECK_STATUS extended operation with specified filter ++ 3. Verify the operation behaves as expected ++ :expectedresults: ++ 1. Connection succeeds (or not for anonymous) ++ 2. Extended operation is sent ++ 3. Server rejects with expected error or accept it as expected ++ """ ++ supplier1 = topology_m2.ms["supplier1"] ++ # Determine bind credentials ++ ++ # Create payload ++ payload = create_cleanruv_payload(filter_bytes) ++ log.info(f" Payload: {payload!r}") ++ extreq = ldap.extop.ExtendedRequest(REPL_CLEANRUV_CHECK_STATUS_OID, payload) ++ ++ check_extop(supplier1, init_bind_user, extreq, bind_type, filter_bytes, expected_exception, test_description) ++ ++ ++@pytest.mark.parametrize("extop_oid", [ ++ REPL_START_NSDS50_REPLICATION_REQUEST_OID, ++ REPL_END_NSDS50_REPLICATION_REQUEST_OID, ++ REPL_NSDS50_REPLICATION_ENTRY_REQUEST_OID, ++ REPL_NSDS50_REPLICATION_RESPONSE_OID, ++ REPL_NSDS50_UPDATE_INFO_CONTROL_OID, ++ REPL_NSDS50_INCREMENTAL_PROTOCOL_OID, ++ REPL_NSDS50_TOTAL_PROTOCOL_OID, ++ REPL_NSDS71_INCREMENTAL_PROTOCOL_OID, ++ REPL_NSDS71_TOTAL_PROTOCOL_OID, ++ REPL_NSDS71_REPLICATION_ENTRY_REQUEST_OID, ++ REPL_START_NSDS90_REPLICATION_REQUEST_OID, ++ REPL_NSDS90_REPLICATION_RESPONSE_OID, ++ REPL_CLEANRUV_OID, ++ REPL_ABORT_CLEANRUV_OID, ++ REPL_CLEANRUV_GET_MAXCSN_OID, ++ REPL_CLEANRUV_CHECK_STATUS_OID, ++ REPL_ABORT_SESSION_OID, ++]) ++def test_anonymous_extops(topology_m2, init_bind_user, extop_oid): ++ """Test that all replication extended operations fail if anonymous. ++ ++ Tests that anonymous (unauthenticated) connections cannot invoke any ++ replication extended operations, ensuring proper authentication is required. ++ ++ :id: f6a7b8c9-d0e1-4f5a-2b3c-4d5e6f7a8b9c ++ :parametrized: yes ++ :setup: Replication setup with two suppliers ++ :steps: ++ 1. Create anonymous connection (no bind) ++ 2. Attempt to send extended operation with specified OID ++ 3. Verify operation is rejected ++ :expectedresults: ++ 1. Anonymous connection established ++ 2. Extended operation is sent ++ 3. Operation fails with INSUFFICIENT_ACCESS, SERVER_DOWN, or PROTOCOL_ERROR ++ """ ++ supplier1 = topology_m2.ms["supplier1"] ++ filter_bytes = 'foo' ++ payload = create_cleanruv_payload(b'foo') ++ extreq = ldap.extop.ExtendedRequest(extop_oid, payload) ++ expected_exception = ( ldap.INSUFFICIENT_ACCESS, ldap.SERVER_DOWN, ldap.PROTOCOL_ERROR ) ++ if extop_oid in (REPL_END_NSDS50_REPLICATION_REQUEST_OID, ): ++ # end session extended operation return success without ++ # doing anything if the replica is not acquired ++ expected_exception = None ++ bind_type = BIND_ANONYMOUS ++ test_description = f'Test {extop_oid} extended operation on anonymous connection' ++ check_extop(supplier1, init_bind_user, extreq, bind_type, filter_bytes, expected_exception, test_description) ++ ++ ++def test_authorization_revocation(topology_m2, init_bind_user): ++ """Test that removing a user from replication group revokes their authorization. ++ ++ This test validates that: ++ - Cached bind DNs work correctly after authentication ++ - Removing a user from the replication group doesn't immediately affect cached credentials ++ - Cache is automatically evicted after timeout ++ - After cache eviction, authorization is re-evaluated and unauthorized users are rejected ++ - Re-adding user to the group restores authorization ++ ++ :id: a7b8c9d0-e1f2-4a5b-3c4d-5e6f7a8b9c0d ++ :setup: Replication setup with two suppliers, authorized user in replication group ++ :steps: ++ 1. Verify authorized user can send CleanRUV extended operation (populates cache) ++ 2. Remove user from replication_managers group ++ 3. Wait for bind DN cache eviction (12 seconds) ++ 4. Verify same user now gets INSUFFICIENT_ACCESS (cache miss, re-authorization fails) ++ 5. Re-add user to replication_managers group ++ 6. Wait for group membership cache sync ++ 7. Verify operation works again after re-authorization ++ :expectedresults: ++ 1. Operation succeeds (cache populated with authorized DN) ++ 2. User is removed from group ++ 3. Cache eviction completes ++ 4. Operation fails with INSUFFICIENT_ACCESS or OPERATIONS_ERROR ++ 5. User is re-added to group ++ 6. Group membership synchronized ++ 7. Operation succeeds again (user re-authorized) ++ :cleanup: ++ - User is re-added to group (in finally block) ++ - Group cache is synchronized (in finally block) ++ """ ++ supplier1 = topology_m2.ms["supplier1"] ++ supplier2 = topology_m2.ms["supplier2"] ++ groups = Groups(supplier1, basedn=DEFAULT_SUFFIX, rdn=None) ++ repl_group = groups.get(dn=f'cn=replication_managers,{DEFAULT_SUFFIX}') ++ binddn = init_bind_user[BIND_AUTHORIZED][0] ++ ++ valid_filter = b"(nsds5ReplicaCleanRUV=5:1234567890:no:0:dc=example,dc=com)" ++ payload = create_cleanruv_payload(valid_filter) ++ extreq = ldap.extop.ExtendedRequest(REPL_CLEANRUV_CHECK_STATUS_OID, payload) ++ ++ test_description = 'Check CleanRuv_Check_Status with valid user' ++ expected_exception = None ++ ++ try: ++ # Step 1: Verify authorized user can send CleanRUV extended operation ++ log.info(" Step 1: Verify authorized user can send operation (populate cache)") ++ check_extop(supplier1, init_bind_user, extreq, BIND_AUTHORIZED, valid_filter, expected_exception, test_description) ++ log.info(" User DN is now cached") ++ ++ # Step 2: Remove user from replication_managers group ++ log.info(" Step 2: Remove user from replication_managers group") ++ repl_group.remove_member(binddn) ++ log.info(f" User {binddn} removed from group") ++ ++ # Step 3: Wait enough time to ensure that bind DN cache is flushed ++ log.info(f" Step 3: Wait for group membership cache sync ({REPL_GROUP_SYNC_DELAY} seconds)") ++ time.sleep(REPL_GROUP_SYNC_DELAY) ++ log.info(" Group cache synchronized") ++ ++ # Step 4: Verify user now gets INSUFFICIENT_ACCESS ++ log.info(" Step 4: Verify operation now fails (cache miss, re-authorization)") ++ expected_exception = (ldap.OPERATIONS_ERROR, ldap.INSUFFICIENT_ACCESS) ++ check_extop(supplier1, init_bind_user, extreq, BIND_AUTHORIZED, valid_filter, expected_exception, test_description) ++ log.info(" PASS: User correctly rejected after cache eviction") ++ ++ finally: ++ # Step 5: Cleanup - Re-add user to group ++ log.info(" Step 5: Re-add user to replication_managers group (cleanup)") ++ repl_group.ensure_member(binddn) ++ log.info(f" User {binddn} re-added to group") ++ ++ # Step 6: Wait for group membership cache sync ++ log.info(f" Step 6: Wait for group membership cache sync ({REPL_GROUP_SYNC_DELAY} seconds)") ++ time.sleep(REPL_GROUP_SYNC_DELAY) ++ log.info(" Group cache synchronized") ++ ++ # Step 7: Double check that the operation is now working again ++ log.info(" Step 7: Verify operation works again after re-authorization") ++ expected_exception = None ++ check_extop(supplier1, init_bind_user, extreq, BIND_AUTHORIZED, valid_filter, expected_exception, test_description) ++ log.info(" PASS: User successfully re-authorized") ++ ++ ++if __name__ == '__main__': ++ # Run with: pytest -v test_cleanruv_extop_security.py ++ import sys ++ sys.exit(pytest.main(["-v", os.path.abspath(__file__)])) +diff --git a/ldap/servers/plugins/replication/repl5.h b/ldap/servers/plugins/replication/repl5.h +index 44f8052ed..0bdbf1f6e 100644 +--- a/ldap/servers/plugins/replication/repl5.h ++++ b/ldap/servers/plugins/replication/repl5.h +@@ -279,6 +279,7 @@ int multisupplier_extop_cleanruv(Slapi_PBlock *pb); + int multisupplier_extop_abort_cleanruv(Slapi_PBlock *pb); + int multisupplier_extop_cleanruv_get_maxcsn(Slapi_PBlock *pb); + int multisupplier_extop_cleanruv_check_status(Slapi_PBlock *pb); ++bool check_replica_acquired(Slapi_PBlock *pb); + int extop_noop(Slapi_PBlock *pb); + struct berval *NSDS50StartReplicationRequest_new(const char *protocol_oid, + const char *repl_root, +@@ -745,6 +746,7 @@ int replica_update_csngen_state_ext(Replica *r, const RUV *ruv, const CSN *extra + CSN *replica_get_purge_csn(const Replica *r); + int replica_log_ruv_elements(const Replica *r); + void replica_enumerate_replicas(FNEnumReplica fn, void *arg); ++void replica_config_enumerate_replicas(FNEnumReplica fn, void *arg); + int replica_reload_ruv(Replica *r); + int replica_check_for_data_reload(Replica *r, void *arg); + /* the functions below manipulate replica dn hash */ +diff --git a/ldap/servers/plugins/replication/repl5_replica_config.c b/ldap/servers/plugins/replication/repl5_replica_config.c +index cfc29d99e..25a7463ef 100644 +--- a/ldap/servers/plugins/replication/repl5_replica_config.c ++++ b/ldap/servers/plugins/replication/repl5_replica_config.c +@@ -1297,6 +1297,78 @@ _replica_config_get_mtnode_ext(const Slapi_Entry *e) + return ext; + } + ++/* Helper callback to collect replica roots into a charray */ ++static int ++replica_collect_root_callback(Replica *r, void *arg) ++{ ++ char ***roots = (char ***)arg; ++ const char *root = slapi_sdn_get_dn(replica_get_root(r)); ++ ++ if (root) { ++ charray_add(roots, slapi_ch_strdup(root)); ++ } ++ return 0; ++} ++ ++/* ++ * Enumerate replicas without holding locks during callbacks. ++ * This function is slower than replica_enumerate_replicas buf safer ++ * as it prevents deadlocks when callbacks need to acquire other locks. ++ * ++ * Algorithm: ++ * 1. Collect replica roots with hash lock held (brief) ++ * 2. For each root, get mapping tree node ++ * 3. Hold s_configLock briefly to safely access mtnode_ext->replica ++ * 4. Acquire object reference to keep replica alive ++ * 5. Call callback without any locks held ++ * 6. Release object reference ++ */ ++void ++replica_config_enumerate_replicas(FNEnumReplica fn, void *arg) ++{ ++ char **roots = NULL; ++ ++ /* Step 1: Collect replica roots using the hash lock */ ++ replica_enumerate_replicas(replica_collect_root_callback, &roots); ++ ++ /* Step 2: Iterate without holding the hash lock */ ++ if (roots) { ++ for (size_t i = 0; roots[i] != NULL; i++) { ++ Slapi_DN sdn; ++ mapping_tree_node *mtnode; ++ multisupplier_mtnode_extension *mtnode_ext; ++ Object *replica_obj = NULL; ++ Replica *r; ++ ++ slapi_sdn_init_dn_byval(&sdn, roots[i]); ++ ++ /* CRITICAL: Hold s_configLock when accessing mapping tree and mtnode_ext */ ++ PR_Lock(s_configLock); ++ mtnode = slapi_get_mapping_tree_node_by_dn(&sdn); ++ if (mtnode) { ++ mtnode_ext = (multisupplier_mtnode_extension *)repl_con_get_ext(REPL_CON_EXT_MTNODE, mtnode); ++ if (mtnode_ext && mtnode_ext->replica) { ++ replica_obj = mtnode_ext->replica; ++ object_acquire(replica_obj); /* Acquire while lock held */ ++ } ++ } ++ PR_Unlock(s_configLock); ++ ++ slapi_sdn_done(&sdn); ++ ++ if (replica_obj) { ++ r = (Replica *)object_get_data(replica_obj); ++ if (r) { ++ fn(r, arg); /* Call without any locks held */ ++ } ++ object_release(replica_obj); ++ replica_obj = NULL; ++ } ++ } ++ charray_free(roots); ++ } ++} ++ + + /* This thread runs the tests of csn generator. + * It will log a set of csn generated while simulating local and remote time skews +diff --git a/ldap/servers/plugins/replication/repl5_total.c b/ldap/servers/plugins/replication/repl5_total.c +index 25ca66ad2..aae940a86 100644 +--- a/ldap/servers/plugins/replication/repl5_total.c ++++ b/ldap/servers/plugins/replication/repl5_total.c +@@ -760,6 +760,19 @@ multisupplier_extop_NSDS50ReplicationEntry(Slapi_PBlock *pb) + slapi_pblock_get(pb, SLAPI_CONN_ID, &connid); + slapi_pblock_get(pb, SLAPI_OPERATION_ID, &opid); + ++ /* Check that connection is a replication session */ ++ if (!check_replica_acquired(pb)) { ++ slapi_log_err(SLAPI_LOG_ERR, repl_plugin_name, ++ "multisupplier_extop_NSDS50ReplicationEntry - " ++ "Attempt to send entries outside of a replication session conn=%" PRIu64 " op=%d\n", ++ connid, opid); ++ slapi_pblock_get(pb, SLAPI_CONNECTION, &conn); ++ if (conn) { ++ slapi_disconnect_server(conn); ++ } ++ return LDAP_INSUFFICIENT_ACCESS; ++ } ++ + /* Decode the extended operation */ + rc = decode_total_update_extop(pb, &e); + +diff --git a/ldap/servers/plugins/replication/repl_extop.c b/ldap/servers/plugins/replication/repl_extop.c +index 818d99431..53e311621 100644 +--- a/ldap/servers/plugins/replication/repl_extop.c ++++ b/ldap/servers/plugins/replication/repl_extop.c +@@ -12,6 +12,7 @@ + #endif + + ++#include + #include "slapi-plugin.h" + #include "repl5.h" + #include "repl5_prot_private.h" +@@ -45,6 +46,134 @@ + */ + static int check_replica_id_uniqueness(Replica *replica, RUV *supplier_ruv); + ++typedef struct check_updatedn_data ++{ ++ const Slapi_DN *bind_sdn; ++ bool is_updatedn; ++} check_updatedn_data; ++ ++/* ++ * Callback function for replica_enumerate_replicas() ++ * Checks if the bind DN in the data structure is an updatedn for the given replica ++ */ ++static int ++check_updatedn_callback(Replica *replica, void *arg) ++{ ++ check_updatedn_data *data = (check_updatedn_data *)arg; ++ ++ if (data->is_updatedn) { ++ /* Already found a match, no need to continue */ ++ return 0; ++ } ++ if (replica_is_updatedn(replica, data->bind_sdn)) { ++ data->is_updatedn = true; ++ } ++ return 0; ++} ++ ++/* ++ * Check if the connection bind DN is an allowed bind DN for at least one of the replicas ++ * ++ * This function caches the last successful bind DN to avoid repeated replica enumeration ++ * for the same connection. (Useful mostly for bulk import entries) ++ * ++ * Returns: ++ * true - if the bind DN is an updatedn for at least one replica ++ * false - if the bind DN is not an updatedn for any replica, or if connection is anonymous ++ */ ++static bool ++check_replica_auth(Slapi_PBlock *pb) ++{ ++ char *bind_dn = NULL; ++ Slapi_DN *bind_sdn = NULL; ++ check_updatedn_data data = {0}; ++ bool result = false; ++ int isroot = 0; ++ ++ slapi_pblock_get(pb, SLAPI_REQUESTOR_ISROOT, &isroot); ++ if (isroot) { ++ return true; ++ } ++ ++ /* Get the bind DN from the connection */ ++ slapi_pblock_get(pb, SLAPI_CONN_DN, &bind_dn); ++ if (bind_dn == NULL) { ++ /* No bind DN, connection is anonymous */ ++ slapi_log_err(SLAPI_LOG_ERR, repl_plugin_name, "check_replica_auth - " ++ "Attempting replication extended operation while anonymous.\n"); ++ return false; ++ } ++ ++ bind_sdn = slapi_sdn_new_dn_passin(bind_dn); ++ bind_dn = NULL; /* Consumed by slapi_sdn_new_dn_passin */ ++ /* Check if the bind DN is an updatedn for any replica */ ++ data.bind_sdn = bind_sdn; ++ data.is_updatedn = false; ++ replica_config_enumerate_replicas(check_updatedn_callback, &data); ++ result = data.is_updatedn; ++ ++ if (!result) { ++ slapi_log_err(SLAPI_LOG_ERR, repl_plugin_name, "check_replica_auth - " ++ "Invalid binddn %s to perform replication extended operation\n", ++ slapi_sdn_get_dn(bind_sdn)); ++ } ++ ++ slapi_sdn_free(&bind_sdn); ++ ++ return result; ++} ++ ++/* ++ * Check if the connection extension exists and has an acquired replica. ++ * This verifies that a replication session is active on this connection. ++ * ++ * Returns: ++ * true - if connection extension exists and has an acquired replica ++ * false - if connection extension doesn't exist or no replica is acquired ++ */ ++bool ++check_replica_acquired(Slapi_PBlock *pb) ++{ ++ Slapi_Connection *conn = NULL; ++ consumer_connection_extension *connext = NULL; ++ bool result = false; ++ ++ slapi_pblock_get(pb, SLAPI_CONNECTION, &conn); ++ if (conn == NULL) { ++ return false; ++ } ++ ++ connext = (consumer_connection_extension *)repl_con_get_ext(REPL_CON_EXT_CONN, conn); ++ if (connext != NULL && connext->replica_acquired != NULL) { ++ result = true; ++ } ++ ++ return result; ++} ++ ++/* Check if filter starts with: (attr= */ ++static inline bool ++check_equality_filter(const char *filter, const char *attr) ++{ ++ size_t len = strlen(attr); ++ return (filter[0] == '(' && ++ strncasecmp(filter+1, attr, len) == 0 && ++ filter[1+len] == '=' && isdigit((unsigned char)(filter[2+len]))); ++} ++ ++/* Check that the filter is one of the expected one */ ++static bool ++check_valid_filter(const char *filter) ++{ ++ if (check_equality_filter(filter, type_replicaAbortCleanRUV)) { ++ return true; ++ } ++ if (check_equality_filter(filter, type_replicaCleanRUV)) { ++ return true; ++ } ++ return false; ++} ++ + static int + encode_ruv(BerElement *ber, const RUV *ruv) + { +@@ -695,6 +824,10 @@ multisupplier_extop_StartNSDS50ReplicationRequest(Slapi_PBlock *pb) + struct berval *data = NULL; + int is90 = 0; + ++ if (!check_replica_auth(pb)) { ++ slapi_send_ldap_result(pb, LDAP_INSUFFICIENT_ACCESS, NULL, NULL, 0, NULL); ++ return NSDS50_REPL_PERMISSION_DENIED; ++ } + /* Decode the extended operation */ + if (decode_startrepl_extop(pb, &protocol_oid, &repl_root, &supplier_ruv, + &referrals, &replicacsnstr, &data_guid, &data, &is90) == -1) { +@@ -1270,6 +1403,17 @@ multisupplier_extop_EndNSDS50ReplicationRequest(Slapi_PBlock *pb) + PRUint64 connid = 0; + int opid = -1; + ++ if (!check_replica_acquired(pb)) { ++ /* At this point we do not know the authentication status ++ * If it is a proper end session without acquired replica ++ * there is nothing to do. ++ * So lets just respond NSDS50_REPL_REPLICA_RELEASE_SUCCEEDED ++ * bypassing decoding the payload and avoid associated risk ++ * if it is an attack ++ */ ++ response = NSDS50_REPL_REPLICA_RELEASE_SUCCEEDED; ++ goto send_response; ++ } + /* Decode the extended operation */ + if (decode_endrepl_extop(pb, &repl_root) == -1) { + response = NSDS50_REPL_DECODING_ERROR; +@@ -1485,6 +1629,10 @@ multisupplier_extop_abort_cleanruv(Slapi_PBlock *pb) + char *iter = NULL; + int rc = LDAP_SUCCESS; + ++ if (!check_replica_auth(pb)) { ++ /* Someone is trying something nasty */ ++ return LDAP_INSUFFICIENT_ACCESS; ++ } + slapi_pblock_get(pb, SLAPI_EXT_OP_REQ_OID, &extop_oid); + slapi_pblock_get(pb, SLAPI_EXT_OP_REQ_VALUE, &extop_payload); + +@@ -1604,6 +1752,10 @@ multisupplier_extop_cleanruv(Slapi_PBlock *pb) + int rid = 0; + int rc = LDAP_OPERATIONS_ERROR; + ++ if (!check_replica_auth(pb)) { ++ /* Someone is trying something nasty */ ++ return LDAP_INSUFFICIENT_ACCESS; ++ } + slapi_pblock_get(pb, SLAPI_EXT_OP_REQ_OID, &extop_oid); + slapi_pblock_get(pb, SLAPI_EXT_OP_REQ_VALUE, &extop_payload); + +@@ -1783,6 +1935,10 @@ multisupplier_extop_cleanruv_get_maxcsn(Slapi_PBlock *pb) + int rid = 0; + int rc = LDAP_OPERATIONS_ERROR; + ++ if (!check_replica_auth(pb)) { ++ /* Someone is trying something nasty */ ++ return LDAP_INSUFFICIENT_ACCESS; ++ } + slapi_pblock_get(pb, SLAPI_EXT_OP_REQ_OID, &extop_oid); + slapi_pblock_get(pb, SLAPI_EXT_OP_REQ_VALUE, &extop_payload); + +@@ -1850,6 +2006,11 @@ multisupplier_extop_cleanruv_check_status(Slapi_PBlock *pb) + int res = 0; + int rc = LDAP_OPERATIONS_ERROR; + ++ ++ if (!check_replica_auth(pb)) { ++ /* Someone is trying something nasty */ ++ return LDAP_INSUFFICIENT_ACCESS; ++ } + slapi_pblock_get(pb, SLAPI_EXT_OP_REQ_OID, &extop_oid); + slapi_pblock_get(pb, SLAPI_EXT_OP_REQ_VALUE, &extop_payload); + +@@ -1866,6 +2027,12 @@ multisupplier_extop_cleanruv_check_status(Slapi_PBlock *pb) + "CleanAllRUV Task - Check Status Task: failed to decode payload. Aborting ext op\n"); + goto free_and_return; + } ++ if (!check_valid_filter(filter)) { ++ /* Someone is attempting something nasty */ ++ slapi_log_err(SLAPI_LOG_ERR, repl_plugin_name, "multisupplier_extop_cleanruv_check_status - " ++ "CleanAllRUV Task - Unexpected filter provided as payload. Aborting ext op\n"); ++ goto free_and_return; ++ } + + search_pb = slapi_pblock_new(); + slapi_search_internal_set_pb(search_pb, "cn=config", LDAP_SCOPE_SUBTREE, +-- +2.55.0 + diff --git a/0086-Issue-7554-deref-plugin-null-pointer-dereference-if-.patch b/0086-Issue-7554-deref-plugin-null-pointer-dereference-if-.patch new file mode 100644 index 0000000..aa0f1e6 --- /dev/null +++ b/0086-Issue-7554-deref-plugin-null-pointer-dereference-if-.patch @@ -0,0 +1,45 @@ +From 7d9b7157234e628ecc32baab16a09eef666962d5 Mon Sep 17 00:00:00 2001 +From: Mark Reynolds +Date: Wed, 3 Jun 2026 17:52:09 -0400 +Subject: [PATCH] Issue 7554 - deref plugin null pointer dereference if + ber_init fails + +Description: + +**CWE**: CWE-476 (NULL Pointer Dereference) + +A flaw in the 389 Directory Server's dereference control plugin allows an +unauthenticated attacker to crash the LDAP server when the system is under +memory pressure(OOM). The deref plugin, enabled by default, fails to check for +a memory allocation failure before using the result, causing the server +process to terminate. + +CI test 'test_deref_and_access_control' already covers this fix. + +relates: https://github.com/389ds/389-ds-base/issues/7554 + +Reviewed by: tbordaz & progier(Thanks!!) +--- + ldap/servers/plugins/deref/deref.c | 6 ++++++ + 1 file changed, 6 insertions(+) + +diff --git a/ldap/servers/plugins/deref/deref.c b/ldap/servers/plugins/deref/deref.c +index fc1c10f71..fc157f6d6 100644 +--- a/ldap/servers/plugins/deref/deref.c ++++ b/ldap/servers/plugins/deref/deref.c +@@ -357,6 +357,12 @@ deref_parse_ctrl_value(DerefSpecList *speclist, const struct berval *ctrlbv, int + } + + ber = ber_init((struct berval *)ctrlbv); ++ if (!ber) { ++ *ldapcode = LDAP_UNWILLING_TO_PERFORM; ++ *ldaperrtext = "Deref control parsing failed to initialize BER element"; ++ return; ++ } ++ + for (tag = ber_first_element(ber, &len, &last); + (tag != LBER_ERROR) && (tag != LBER_END_OF_SEQORSET); + tag = ber_next_element(ber, &len, last)) { +-- +2.55.0 + diff --git a/0087-Issue-CVE-2026-15722-pre-authentication-stack-buffer.patch b/0087-Issue-CVE-2026-15722-pre-authentication-stack-buffer.patch new file mode 100644 index 0000000..6d7e3a5 --- /dev/null +++ b/0087-Issue-CVE-2026-15722-pre-authentication-stack-buffer.patch @@ -0,0 +1,41 @@ +From 7f7d430dc5ec9285a8d95b69c591ed0225a0f057 Mon Sep 17 00:00:00 2001 +From: Thierry Bordaz +Date: Wed, 15 Jul 2026 11:39:58 +0200 +Subject: [PATCH] Issue CVE-2026-15722 - pre-authentication stack buffer + overflow + +Bug description: + The function get_ruvelement_from_berval() in ldap/servers/plugins/replication/repl5_ruv.c + parses a replica ID from a Replica Update Vector (RUV) berval by copying digit + characters into a 16-byte stack buffer (ridbuff[RIDSTR_SIZE]). The copy loop + has no bounds check + it keeps writing as long as isdigit() returns true. + A berval with more than 16 digit characters in the replica ID position overflows the buffer. + +Fix description: + if the berval contains more than RIDSTR_SIZE-1 digits it fails + +fixes: TBD + +Reviewed by: Mark Reynolds +--- + ldap/servers/plugins/replication/repl5_ruv.c | 3 +++ + 1 file changed, 3 insertions(+) + +diff --git a/ldap/servers/plugins/replication/repl5_ruv.c b/ldap/servers/plugins/replication/repl5_ruv.c +index 40869c486..4df6ea135 100644 +--- a/ldap/servers/plugins/replication/repl5_ruv.c ++++ b/ldap/servers/plugins/replication/repl5_ruv.c +@@ -1987,6 +1987,9 @@ get_ruvelement_from_berval(const struct berval *bval) + /* replica id must be here */ + i = 0; + while (isdigit(bval->bv_val[urlbegin])) { ++ if (i >= RIDSTR_SIZE - 1) { ++ goto loser; ++ } + ridbuff[i] = bval->bv_val[urlbegin]; + i++; + urlbegin++; +-- +2.55.0 + diff --git a/389-ds-base.spec b/389-ds-base.spec index 6223275..c9effc9 100644 --- a/389-ds-base.spec +++ b/389-ds-base.spec @@ -2,7 +2,7 @@ ## (rpmautospec version 0.6.5) ## RPMAUTOSPEC: autorelease, autochangelog %define autorelease(e:s:pb:n) %{?-p:0.}%{lua: - release_number = 8; + release_number = 9; base_release_number = tonumber(rpm.expand("%{?-b*}%{!?-b:1}")); print(release_number + base_release_number - 1); }%{?-e:.%{-e*}}%{?-s:.%{-s*}}%{!?-n:%{?dist}} @@ -390,6 +390,9 @@ Patch: 0081-Issue-7549-Substring-index-should-validate-minimum-n.patc Patch: 0082-Issue-7593-Reject-invalid-SASL-packet-length-values-.patch Patch: 0083-Issue-7593-Fix-testimony-docstring-for-SASL-overflow.patch Patch: 0084-Security-Advisory-Heap-Buffer-Overflow-in-sasl_io_re.patch +Patch: 0085-CVE-2026-11770-pre-auth-LDAP-filter-injection-in-Cle.patch +Patch: 0086-Issue-7554-deref-plugin-null-pointer-dereference-if-.patch +Patch: 0087-Issue-CVE-2026-15722-pre-authentication-stack-buffer.patch %description 389 Directory Server is an LDAPv3 compliant server. The base package includes @@ -942,6 +945,16 @@ exit 0 %changelog ## START: Generated by rpmautospec +* Wed Jul 29 2026 Viktor Ashirov - 3.2.0-9 +- Bump version to 3.2.0-9 +- Resolves: RHEL-183075 - CVE-2026-11770 389-ds-base: 389-ds-base: pre-auth + LDAP filter injection in CleanAllRUV status check [rhel-10.2.z] +- Resolves: RHEL-190776 - CVE-2026-11788 389-ds-base: 389-ds-base: NULL + pointer dereference in deref control plugin BER parser [rhel-10.2.z] +- Resolves: RHEL-210874 - CVE-2026-15722 389-ds-base: 389-ds-base: pre- + authentication stack buffer overflow in get_ruvelement_from_berval() via + unbounded replica ID parsing [rhel-10.2.z] + * Fri Jun 26 2026 Viktor Ashirov - 3.2.0-8 - Bump version to 3.2.0-8 - Resolves: RHEL-182152 - CVE-2026-11610 389-ds-base: 389-ds-base: Heap