diff --git a/0015-Issue-7200-repl-agmt-create-doesn-t-set-some-paramet.patch b/0015-Issue-7200-repl-agmt-create-doesn-t-set-some-paramet.patch new file mode 100644 index 0000000..19024b4 --- /dev/null +++ b/0015-Issue-7200-repl-agmt-create-doesn-t-set-some-paramet.patch @@ -0,0 +1,171 @@ +From 23f4b544c7db3fe3100b0f21454828f71891fee3 Mon Sep 17 00:00:00 2001 +From: Viktor Ashirov +Date: Thu, 23 Jul 2026 09:58:40 +0200 +Subject: [PATCH] Issue 7200 - repl-agmt create doesn't set some parameters + (#7663) + +Bug Description: +The `add_agmt()` function in the dsconf CLI silently ignored flow +and timeout parameters: + --conn-timeout + --protocol-timeout + --wait-async-results + --busy-wait-time + --session-pause-time + --flow-control-window + --flow-control-pause +The CLI args and attribute mappings existed, but the properties dict was +never populated with these values during agreement creation. + +Fix Description: +Add the missing args-to-properties assignments. + +Fixes: https://github.com/389ds/389-ds-base/issues/7200 + +Reviewed by: @mreynolds389 (Thanks!) +--- + .../clu/dsconf_agmt_timeout_attrs_test.py | 106 ++++++++++++++++++ + src/lib389/lib389/cli_conf/replication.py | 14 +++ + 2 files changed, 120 insertions(+) + create mode 100644 dirsrvtests/tests/suites/clu/dsconf_agmt_timeout_attrs_test.py + +diff --git a/dirsrvtests/tests/suites/clu/dsconf_agmt_timeout_attrs_test.py b/dirsrvtests/tests/suites/clu/dsconf_agmt_timeout_attrs_test.py +new file mode 100644 +index 000000000..8c50e3dc2 +--- /dev/null ++++ b/dirsrvtests/tests/suites/clu/dsconf_agmt_timeout_attrs_test.py +@@ -0,0 +1,106 @@ ++# --- 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 --- ++ ++import os ++import logging ++import pytest ++from test389.topologies import topology_st as topo ++from lib389.cli_base import FakeArgs ++from lib389.cli_conf.replication import add_agmt ++from lib389.replica import Replicas ++from lib389._constants import DEFAULT_SUFFIX ++ ++pytestmark = pytest.mark.tier1 ++ ++DEBUGGING = os.getenv("DEBUGGING", default=False) ++if DEBUGGING: ++ logging.getLogger(__name__).setLevel(logging.DEBUG) ++else: ++ logging.getLogger(__name__).setLevel(logging.INFO) ++log = logging.getLogger(__name__) ++ ++ ++def test_agmt_create_timeout_and_flow_control_attrs(topo): ++ """Verify add_agmt passes timeout and flow-control attributes ++ through to the agreement entry ++ ++ :id: 2c95ce33-7f25-480a-b827-c03ee10da650 ++ :setup: Standalone instance ++ :steps: ++ 1. Enable replication on the instance as a supplier ++ 2. Add agreement with all timeout/flow-control arguments set ++ 3. Read back the agreement and verify each attribute values ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success ++ """ ++ inst = topo.standalone ++ ++ replicas = Replicas(inst) ++ replicas.create(properties={ ++ 'cn': 'replica', ++ 'nsDS5ReplicaRoot': DEFAULT_SUFFIX, ++ 'nsDS5ReplicaId': '1', ++ 'nsDS5ReplicaType': '3', ++ 'nsDS5Flags': '1', ++ }) ++ ++ EXPECTED = { ++ 'nsds5replicatimeout': '30', ++ 'nsds5replicaprotocoltimeout': '120', ++ 'nsds5replicawaitforasyncresults': '500', ++ 'nsds5replicabusywaittime': '5', ++ 'nsds5replicaSessionPauseTime': '3', ++ 'nsds5replicaflowcontrolwindow': '2000', ++ 'nsds5replicaflowcontrolpause': '4', ++ } ++ ++ args = FakeArgs() ++ # Required args ++ args.AGMT_NAME = ['test-timeout-agmt'] ++ args.suffix = DEFAULT_SUFFIX ++ args.host = 'localhost' ++ args.port = '33333' ++ args.conn_protocol = 'LDAP' ++ args.bind_dn = 'cn=replmgr,cn=config' ++ args.bind_passwd = 'replmgr' ++ args.bind_method = 'SIMPLE' ++ args.init = False ++ ++ # Optional attributes that we're testing ++ args.conn_timeout = EXPECTED['nsds5replicatimeout'] ++ args.protocol_timeout = EXPECTED['nsds5replicaprotocoltimeout'] ++ args.wait_async_results = EXPECTED['nsds5replicawaitforasyncresults'] ++ args.busy_wait_time = EXPECTED['nsds5replicabusywaittime'] ++ args.session_pause_time = EXPECTED['nsds5replicaSessionPauseTime'] ++ args.flow_control_window = EXPECTED['nsds5replicaflowcontrolwindow'] ++ args.flow_control_pause = EXPECTED['nsds5replicaflowcontrolpause'] ++ ++ # The rest is None ++ args.__class__.__getattr__ = lambda self, name: None ++ ++ # Create new agreement with optional attributes ++ add_agmt(inst, None, log, args) ++ ++ # Read it back ++ replica = replicas.get(DEFAULT_SUFFIX) ++ agmt = replica.get_agreements().list()[0] ++ ++ for attr, expected_val in EXPECTED.items(): ++ actual = agmt.get_attr_val_utf8(attr) ++ log.info(f"Checking {attr}: expected={expected_val}, actual={actual}") ++ assert actual == expected_val, \ ++ f"Attribute {attr}: expected '{expected_val}', got '{actual}'" ++ ++ log.info("All timeout and flow-control attributes verified successfully") ++ ++ ++if __name__ == '__main__': ++ CURRENT_FILE = os.path.realpath(__file__) ++ pytest.main(f"-s {CURRENT_FILE}") +diff --git a/src/lib389/lib389/cli_conf/replication.py b/src/lib389/lib389/cli_conf/replication.py +index e6bc823c6..f57f34031 100644 +--- a/src/lib389/lib389/cli_conf/replication.py ++++ b/src/lib389/lib389/cli_conf/replication.py +@@ -942,6 +942,20 @@ def add_agmt(inst, basedn, log, args): + properties['nsds5replicatedattributelisttotal'] = frac_total_list + if args.strip_list is not None: + properties['nsds5replicastripattrs'] = args.strip_list ++ if args.conn_timeout is not None: ++ properties['nsds5replicatimeout'] = args.conn_timeout ++ if args.protocol_timeout is not None: ++ properties['nsds5replicaprotocoltimeout'] = args.protocol_timeout ++ if args.wait_async_results is not None: ++ properties['nsds5replicawaitforasyncresults'] = args.wait_async_results ++ if args.busy_wait_time is not None: ++ properties['nsds5replicabusywaittime'] = args.busy_wait_time ++ if args.session_pause_time is not None: ++ properties['nsds5replicaSessionPauseTime'] = args.session_pause_time ++ if args.flow_control_window is not None: ++ properties['nsds5replicaflowcontrolwindow'] = args.flow_control_window ++ if args.flow_control_pause is not None: ++ properties['nsds5replicaflowcontrolpause'] = args.flow_control_pause + + # Handle the optional bootstrap settings + if args.bootstrap_bind_dn is not None: +-- +2.55.0 + diff --git a/0016-Issue-7705-With-memberOfEntryScope-set-deferred-memb.patch b/0016-Issue-7705-With-memberOfEntryScope-set-deferred-memb.patch new file mode 100644 index 0000000..111ca1a --- /dev/null +++ b/0016-Issue-7705-With-memberOfEntryScope-set-deferred-memb.patch @@ -0,0 +1,183 @@ +From 12c345712da92cc04a9ae0ff52f16847ee0cd00f Mon Sep 17 00:00:00 2001 +From: Simon Pichugin +Date: Mon, 17 Aug 2026 17:43:31 -0700 +Subject: [PATCH] Issue 7705 - With memberOfEntryScope set, deferred memberOf + skips MODIFY operations (#7706) + +Description: deferred_mod_func checks the entry scope against a pre-op entry +it never reads from the task pblock. With memberOfEntryScope set the check +always fails, so the memberOf fanout of every grouping attribute MODIFY +is silently skipped, for direct and replicated operations alike. +Possibly, a regression from the issue 7035 scoping refactor. + +Read the post-op entry the way the direct modify path does, and add +a regression test. + +Fixes: https://github.com/389ds/389-ds-base/issues/7705 + +Reviewed by: @tbordaz (Thanks!) +--- + .../memberof_deferred_scope_test.py | 129 ++++++++++++++++++ + ldap/servers/plugins/memberof/memberof.c | 3 +- + 2 files changed, 131 insertions(+), 1 deletion(-) + create mode 100644 dirsrvtests/tests/suites/memberof_plugin/memberof_deferred_scope_test.py + +diff --git a/dirsrvtests/tests/suites/memberof_plugin/memberof_deferred_scope_test.py b/dirsrvtests/tests/suites/memberof_plugin/memberof_deferred_scope_test.py +new file mode 100644 +index 000000000..f386fc3e0 +--- /dev/null ++++ b/dirsrvtests/tests/suites/memberof_plugin/memberof_deferred_scope_test.py +@@ -0,0 +1,129 @@ ++# --- 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 --- ++# ++import logging ++import pytest ++import os ++import time ++from lib389._constants import DEFAULT_SUFFIX ++from test389.topologies import topology_st as topo ++from lib389.plugins import MemberOfPlugin ++from lib389.idm.user import UserAccounts ++from lib389.idm.group import Groups ++from lib389.idm.nscontainer import nsContainers ++from lib389.utils import get_default_db_lib ++ ++log = logging.getLogger(__name__) ++ ++EXCLUDED_SUBTREE = f'cn=excluded,{DEFAULT_SUFFIX}' ++ ++ ++def configure_memberof(inst, scope=None, exclude=None): ++ memberof = MemberOfPlugin(inst) ++ memberof.enable() ++ memberof.set_autoaddoc('nsMemberOf') ++ memberof.set_memberofdeferredupdate('on') ++ memberof.remove_all('memberOfEntryScope') ++ memberof.remove_all('memberOfEntryScopeExcludeSubtree') ++ if scope: ++ memberof.set('memberOfEntryScope', scope) ++ if exclude: ++ memberof.set('memberOfEntryScopeExcludeSubtree', exclude) ++ inst.restart() ++ ++ ++def wait_for_memberof(user, group_dn, timeout=10): ++ deadline = time.monotonic() + timeout ++ while time.monotonic() < deadline: ++ values = {v.lower() for v in user.get_attr_vals_utf8('memberOf')} ++ if group_dn.lower() in values: ++ return True ++ time.sleep(0.5) ++ return False ++ ++ ++@pytest.mark.skipif(get_default_db_lib() == "mdb", reason="Not supported over mdb") ++def test_deferred_update_in_entry_scope(topo): ++ """Deferred memberOf updates are applied when memberOfEntryScope is set ++ ++ :id: 2a4877f8-1e63-4a6c-9f35-3c6b0f9d7b41 ++ :setup: Standalone Instance ++ :steps: ++ 1. Enable memberOf with deferred updates, memberOfEntryScope set to ++ the suffix and one exclude subtree ++ 2. Create a user and a group inside the scope ++ 3. Add the user as a member of the group ++ 4. Check the user's memberOf attribute ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success ++ 4. memberOf contains the group DN ++ """ ++ inst = topo.standalone ++ configure_memberof(inst, scope=DEFAULT_SUFFIX, exclude=EXCLUDED_SUBTREE) ++ ++ user = UserAccounts(inst, DEFAULT_SUFFIX).create_test_user(uid=1001) ++ group = Groups(inst, DEFAULT_SUFFIX).create(properties={ ++ 'cn': 'deferred_scope_group', ++ 'description': 'group inside the entry scope', ++ }) ++ group.add_member(user.dn) ++ ++ assert wait_for_memberof(user, group.dn), \ ++ 'memberOf was not applied by the deferred update with entry scope set' ++ ++ ++@pytest.mark.skipif(get_default_db_lib() == "mdb", reason="Not supported over mdb") ++def test_deferred_update_exclude_subtree_honored(topo): ++ """Deferred memberOf updates honor memberOfEntryScopeExcludeSubtree ++ ++ :id: 8c9f4b02-55d1-4f7e-b6a9-70d3e2c1a9d4 ++ :setup: Standalone Instance ++ :steps: ++ 1. Enable memberOf with deferred updates and only an exclude subtree ++ 2. Create a group inside the excluded subtree, a control group in ++ scope and a user outside the excluded subtree ++ 3. Add the user to the excluded group, then to the control group ++ 4. Wait for the control group's memberOf, then check the user ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success ++ 4. memberOf contains the control group but not the excluded group ++ """ ++ inst = topo.standalone ++ configure_memberof(inst, exclude=EXCLUDED_SUBTREE) ++ ++ containers = nsContainers(inst, DEFAULT_SUFFIX) ++ if not containers.exists('excluded'): ++ containers.create(properties={'cn': 'excluded'}) ++ user = UserAccounts(inst, DEFAULT_SUFFIX).create_test_user(uid=1002) ++ excluded_group = Groups(inst, EXCLUDED_SUBTREE, rdn=None).create(properties={ ++ 'cn': 'deferred_excluded_group', ++ 'description': 'group inside the excluded subtree', ++ }) ++ control_group = Groups(inst, DEFAULT_SUFFIX).create(properties={ ++ 'cn': 'deferred_control_group', ++ 'description': 'control group inside the scope', ++ }) ++ excluded_group.add_member(user.dn) ++ control_group.add_member(user.dn) ++ ++ # The deferred list is FIFO: once the control group's update is applied, ++ # the excluded group's update has already been processed ++ assert wait_for_memberof(user, control_group.dn), \ ++ 'memberOf was not applied for the in-scope control group' ++ values = {v.lower() for v in user.get_attr_vals_utf8('memberOf')} ++ assert excluded_group.dn.lower() not in values, \ ++ 'memberOf was applied for a group inside an excluded subtree' ++ ++ ++if __name__ == '__main__': ++ CURRENT_FILE = os.path.realpath(__file__) ++ pytest.main("-s %s" % CURRENT_FILE) +diff --git a/ldap/servers/plugins/memberof/memberof.c b/ldap/servers/plugins/memberof/memberof.c +index a2fa4f61c..f71c0220c 100644 +--- a/ldap/servers/plugins/memberof/memberof.c ++++ b/ldap/servers/plugins/memberof/memberof.c +@@ -770,6 +770,7 @@ deferred_mod_func(MemberofDeferredModTask *task) + + pb = task->pb; + slapi_pblock_get(pb, SLAPI_TARGET_SDN, &sdn); ++ slapi_pblock_get(pb, SLAPI_ENTRY_POST_OP, &post_e); + slapi_log_err(SLAPI_LOG_PLUGIN, MEMBEROF_PLUGIN_SUBSYSTEM, + "deferred_mod_func: target %s\n", slapi_sdn_get_dn(sdn)); + /* get the mod set */ +@@ -792,7 +793,7 @@ deferred_mod_func(MemberofDeferredModTask *task) + mainConfig = memberof_get_config(); + if (memberof_is_grouping_attr(type, mainConfig)) { + interested = 1; +- memberof_set_entry_info(pre_e, NULL, &entry_info); ++ memberof_set_entry_info(post_e, NULL, &entry_info); + if (!memberof_entry_in_scope(mainConfig, &entry_info)) { + /* Entry is not in scope */ + memberof_unlock_config(); +-- +2.55.0 + diff --git a/0017-Issue-7711-Fix-typo-in-accountpolicy-login-history-s.patch b/0017-Issue-7711-Fix-typo-in-accountpolicy-login-history-s.patch new file mode 100644 index 0000000..99dcae7 --- /dev/null +++ b/0017-Issue-7711-Fix-typo-in-accountpolicy-login-history-s.patch @@ -0,0 +1,37 @@ +From be51259db9339cab7c7c3f4b48f1f49ee2caea31 Mon Sep 17 00:00:00 2001 +From: Akshay Sakure <113896074+asakure@users.noreply.github.com> +Date: Mon, 17 Aug 2026 22:26:44 +0530 +Subject: [PATCH] Issue 7711 - Fix typo in accountpolicy --login-history-size + help text (#7713) + +Description: This patch corrects the typo in help text for +--login-history-size argument in the Account Policy plugin CLI. +It currently references lastLoginHistSize, which is not a valid +attribute name. The correct attribute name is lastLoginHistorySize. +It also removes an extra stray closing parenthesis in the same string. + +Fixes: https://github.com/389ds/389-ds-base/issues/7711 + +Signed-off-by: Akshay Sakure + +Reviewed by: mreynolds +--- + src/lib389/lib389/cli_conf/plugins/accountpolicy.py | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/src/lib389/lib389/cli_conf/plugins/accountpolicy.py b/src/lib389/lib389/cli_conf/plugins/accountpolicy.py +index 4cfa2718e..d700cd024 100644 +--- a/src/lib389/lib389/cli_conf/plugins/accountpolicy.py ++++ b/src/lib389/lib389/cli_conf/plugins/accountpolicy.py +@@ -103,7 +103,7 @@ def _add_parser_args(parser): + parser.add_argument('--state-attr', + help='Specifies the primary time attribute used to evaluate an account policy (stateAttrName)') + parser.add_argument('--login-history-size', +- help='Specifies the number of login timestamps to store (lastLoginHistSize) )') ++ help='Specifies the number of login timestamps to store (lastLoginHistorySize)') + parser.add_argument('--check-all-state-attrs', choices=['yes', 'no'], type=str.lower, + help="Check both state and alternate state attributes for account state") + +-- +2.55.0 + diff --git a/0018-Issue-7658-Heap-Buffer-Overflow-in-sasl_io_recv-via-.patch b/0018-Issue-7658-Heap-Buffer-Overflow-in-sasl_io_recv-via-.patch new file mode 100644 index 0000000..6db6e39 --- /dev/null +++ b/0018-Issue-7658-Heap-Buffer-Overflow-in-sasl_io_recv-via-.patch @@ -0,0 +1,645 @@ +From 7e6b5c6d60d4ab527273936996c9d07ee4ff124f Mon Sep 17 00:00:00 2001 +From: Mark Reynolds +Date: Thu, 4 Jun 2026 12:32:56 -0400 +Subject: [PATCH] Issue 7658 - Heap Buffer Overflow in sasl_io_recv() via + Padded SASL UNBIND + +Description: + +In sasl_io.c, function sasl_io_recv(), when sasl_io_start_packet() returns +SASL_IO_BUFFER_NOT_ENCRYPTED (triggered by sending an unencrypted LDAP UNBIND +after SASL layer setup) there is no check that + +sp->encrypted_buffer_count <= len before the memcpy + +relates: https://github.com/389ds/389-ds-base/issues/7658 + +Reviewed by: jchapman & spichugi(Thanks!) +--- + .../suites/sasl/io_overflow_asan_test.py | 372 ++++++++++++++++++ + ldap/servers/slapd/sasl_io.c | 159 +++++--- + 2 files changed, 472 insertions(+), 59 deletions(-) + create mode 100644 dirsrvtests/tests/suites/sasl/io_overflow_asan_test.py + +diff --git a/dirsrvtests/tests/suites/sasl/io_overflow_asan_test.py b/dirsrvtests/tests/suites/sasl/io_overflow_asan_test.py +new file mode 100644 +index 000000000..65fc06303 +--- /dev/null ++++ b/dirsrvtests/tests/suites/sasl/io_overflow_asan_test.py +@@ -0,0 +1,372 @@ ++# --- 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 --- ++# ++import ldap ++import logging ++import pytest ++import os ++import socket ++import struct ++import sys ++import time ++from ldap import sasl as ldap_sasl ++from lib389._constants import DEFAULT_SUFFIX, PASSWORD ++from lib389.idm.user import UserAccounts ++from lib389.utils import check_asan_report ++from test389.topologies import topology_st as topo ++ ++log = logging.getLogger(__name__) ++ ++pytestmark = pytest.mark.tier1 ++ ++PADDED_UNBIND_SIZE = 9000 ++STALL_PADDED_UNBIND_SIZE = 508 ++STALL_IOBLOCK_TIMEOUT_MS = 30000 ++STALL_CLIENT_TIMEOUT_SECONDS = 3 ++UNBIND_CONTROL_VALUE_SIZE = 512 ++ ++ ++def encode_ber_length(length): ++ """Encode a non-negative BER definite length.""" ++ if length < 0x80: ++ return bytes([length]) ++ ++ encoded = length.to_bytes((length.bit_length() + 7) // 8, 'big') ++ return bytes([0x80 | len(encoded)]) + encoded ++ ++ ++def encode_ber_element(tag, value): ++ """Encode a single-byte BER tag and its value.""" ++ return bytes([tag]) + encode_ber_length(len(value)) + value ++ ++ ++def build_padded_unbind(padding_size=PADDED_UNBIND_SIZE): ++ """Build an oversized LDAP UNBIND packet. ++ ++ Normal UNBIND is 7 bytes: ++ 30 05 SEQUENCE, length 5 ++ 02 01 01 INTEGER (msgid) = 1 ++ 42 00 UNBIND request (app 2, primitive, length 0) ++ ++ Padded UNBIND uses 4-byte BER definite length encoding to include ++ attacker-controlled padding after the UNBIND element. The outer SEQUENCE ++ length encompasses the msgid + unbind + padding. The server reads the ++ full packet into encrypted_buffer (based on the outer BER length), then ++ copies ALL of it into the caller's buf via memcpy without bounds check. ++ ++ 30 84 XX XX XX XX SEQUENCE, 4-byte length = 5 + pad_size ++ 02 01 01 INTEGER (msgid) = 1 ++ 42 00 UNBIND request ++ [pad_size bytes of padding] ++ """ ++ inner = b'\x02\x01\x01' # msgid = 1 ++ inner += b'\x42\x00' # UNBIND (application tag 2, primitive, length 0) ++ inner += b'A' * padding_size # attacker-controlled padding ++ ++ # SEQUENCE (0x30) with 4-byte definite length encoding (0x84) ++ length = len(inner) ++ packet = b'\x30\x84' + struct.pack('>I', length) + inner ++ return packet ++ ++ ++def build_controlled_unbind(): ++ """Build a valid UNBIND with a noncritical control larger than 512 bytes.""" ++ control_oid = encode_ber_element(0x04, b'1.3.6.1.4.1.4203.666.11.999') ++ control_value = encode_ber_element(0x04, b'V' * UNBIND_CONTROL_VALUE_SIZE) ++ control = encode_ber_element(0x30, control_oid + control_value) ++ controls = encode_ber_element(0xa0, control) ++ ++ message = b'\x02\x01\x01' # msgid = 1 ++ message += b'\x42\x00' # UNBIND ++ message += controls ++ return encode_ber_element(0x30, message) ++ ++ ++def do_sasl_bind_and_get_fd(host, port, user): ++ """Perform SASL DIGEST-MD5 bind and return the raw socket FD. ++ ++ Uses python-ldap for the SASL handshake, then extracts the underlying ++ socket file descriptor. After the SASL bind with SSF > 0, the server ++ has pushed the SASL I/O layer onto the connection. ++ """ ++ ++ uri = f"ldap://{host}:{port}" ++ log.info(f"[*] Connecting to {uri}") ++ ++ conn = ldap.initialize(uri) ++ conn.protocol_version = ldap.VERSION3 ++ ++ # Set SASL options for DIGEST-MD5 with integrity/confidentiality protection ++ # This ensures SSF > 0, which triggers sasl_io_enable on the server ++ conn.set_option(ldap.OPT_X_SASL_SSF_MIN, 1) ++ conn.set_option(ldap.OPT_X_SASL_SSF_MAX, 256) ++ ++ log.info(f"[*] SASL DIGEST-MD5 bind as user: {user}") ++ ++ # DIGEST-MD5 SASL bind ++ auth = ldap_sasl.digest_md5(user, PASSWORD) ++ try: ++ conn.sasl_interactive_bind_s("", auth) ++ except ldap.LDAPError as e: ++ log.info(f"[-] SASL bind failed: {e}") ++ sys.exit(1) ++ ++ # Verify SSF > 0 (SASL I/O layer is active) ++ ssf = conn.get_option(ldap.OPT_X_SASL_SSF) ++ log.info(f"[+] SASL bind successful, SSF = {ssf}") ++ if ssf == 0: ++ log.info("[-] SSF is 0 -- SASL I/O layer was NOT pushed. Exploit requires SSF > 0.") ++ sys.exit(1) ++ ++ # Get the raw socket file descriptor ++ fd = conn.fileno() ++ log.info(f"[+] Raw socket FD: {fd}") ++ ++ return conn, fd ++ ++ ++def send_padded_unbind(fd, padding_size=PADDED_UNBIND_SIZE): ++ """Send a padded UNBIND directly on the raw socket FD. ++ ++ This bypasses the SASL framing layer on the CLIENT side. The server's ++ sasl_io_recv will see this as an unencrypted LDAP message (first byte ++ is 0x30 = LDAP_TAG_MESSAGE, not SASL framing). ++ ++ The server code path: ++ 1. sasl_io_recv -> sasl_io_start_packet ++ 2. !sp->send_encrypted (PR_FALSE on first read) && *encrypted_buffer == 0x30 ++ 3. Enters unencrypted LDAP path ++ 4. Reads full packet into encrypted_buffer (ber_len bytes) ++ 5. Checks tag == LDAP_REQ_UNBIND (0x42) -- passes ++ 6. Sets encrypted_buffer_count = encrypted_buffer_offset = ber_len + 2 ++ 7. Returns SASL_IO_BUFFER_NOT_ENCRYPTED ++ 8. sasl_io_recv: memcpy(buf, encrypted_buffer, encrypted_buffer_count) ++ where buf is sized to len (caller's buffer), NOT encrypted_buffer_count ++ 9. OVERFLOW: encrypted_buffer_count (pad_size + 7) >> len (caller's buf size) ++ """ ++ packet = build_padded_unbind(padding_size) ++ total_size = len(packet) ++ ++ log.info(f"[*] Sending padded UNBIND: {total_size} bytes total") ++ log.info(f"[*] Padding size: {padding_size} bytes of attacker-controlled data") ++ log.info(f"[*] Packet header: {packet[:10].hex()}") ++ ++ # Send directly on the raw FD, bypassing SASL wrapper ++ with socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM) as sock: ++ sock.sendall(packet) ++ log.info(f"[+] Sent {total_size} bytes on raw socket (bypassing SASL layer)") ++ ++ return total_size ++ ++ ++def send_raw_packet(fd, packet): ++ """Send a complete packet on the raw socket, bypassing the SASL layer.""" ++ with socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM) as sock: ++ sock.sendall(packet) ++ ++ ++def wait_for_connection_close(fd): ++ """Wait briefly for the server to close a raw client socket.""" ++ with socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM) as sock: ++ sock.settimeout(STALL_CLIENT_TIMEOUT_SECONDS) ++ try: ++ data = sock.recv(1) ++ except socket.timeout: ++ pytest.fail( ++ "Plaintext UNBIND stalled in sasl_io_recv instead of being " ++ "drained or rejected" ++ ) ++ ++ assert data == b'', "Server did not close the connection after UNBIND" ++ ++def test_sasl_io_padded_unbind_does_not_stall(topo): ++ """Verify a padded plaintext UNBIND does not stall the SASL I/O layer ++ ++ The LDAP connection buffer is 512 bytes. The padded UNBIND is 519 bytes, ++ so sasl_io_recv must return it over multiple calls. The server must drain ++ the already-buffered remainder or reject the PDU instead of waiting for ++ more network data until nsslapd-ioblocktimeout expires. ++ ++ :id: ee28256e-5f97-4a19-8178-28d1e372695d ++ :setup: Standalone Instance ++ :steps: ++ 1. Configure a fixed 512-byte connection buffer and a 30-second I/O timeout ++ 2. Create a user and perform a SASL DIGEST-MD5 bind with SSF greater than zero ++ 3. Send a 519-byte plaintext UNBIND directly on the raw socket ++ 4. Wait up to 3 seconds for the server to close the connection ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success ++ 4. The connection closes without waiting for the server I/O timeout ++ """ ++ ++ inst = topo.standalone ++ inst.config.set('passwordStorageScheme', 'CLEAR') ++ inst.config.set('nsslapd-connection-buffer', '1') ++ inst.config.set('nsslapd-ioblocktimeout', str(STALL_IOBLOCK_TIMEOUT_MS)) ++ ++ users = UserAccounts(inst, DEFAULT_SUFFIX) ++ user = users.create_test_user(uid=2) ++ user.set('userPassword', PASSWORD) ++ ++ conn, fd = do_sasl_bind_and_get_fd(inst.host, inst.port, 'test_user_2') ++ try: ++ send_padded_unbind(fd, STALL_PADDED_UNBIND_SIZE) ++ ++ wait_for_connection_close(fd) ++ finally: ++ try: ++ conn.unbind_s() ++ except ldap.LDAPError: ++ pass ++ ++ ++def test_sasl_io_large_controlled_unbind_is_processed(topo): ++ """Verify a valid plaintext UNBIND larger than the read buffer is processed ++ ++ LDAP permits controls on an UNBIND request. The SASL compatibility path must ++ return such a request over multiple recv calls instead of rejecting it based ++ on the size of the current connection read buffer. ++ ++ :id: a0496a4c-70e0-4d04-92d6-20f3977489fe ++ :setup: Standalone Instance ++ :steps: ++ 1. Configure a fixed 512-byte connection buffer and unbuffered access logging ++ 2. Create a user and perform a SASL DIGEST-MD5 bind with SSF greater than zero ++ 3. Send a valid plaintext UNBIND containing a large noncritical control ++ 4. Verify the connection closes and the server logs a processed UNBIND ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success ++ 4. The UNBIND is processed normally rather than rejected by the I/O layer ++ """ ++ ++ inst = topo.standalone ++ inst.config.set('passwordStorageScheme', 'CLEAR') ++ inst.config.set('nsslapd-connection-buffer', '1') ++ inst.config.set('nsslapd-ioblocktimeout', str(STALL_IOBLOCK_TIMEOUT_MS)) ++ inst.config.set('nsslapd-accesslog-logbuffering', 'off') ++ ++ users = UserAccounts(inst, DEFAULT_SUFFIX) ++ user = users.create_test_user(uid=3) ++ user.set('userPassword', PASSWORD) ++ ++ conn, fd = do_sasl_bind_and_get_fd(inst.host, inst.port, 'test_user_3') ++ unbind_count = len(inst.ds_access_log.match('.* UNBIND.*')) ++ ++ try: ++ packet = build_controlled_unbind() ++ assert len(packet) > 512 ++ send_raw_packet(fd, packet) ++ wait_for_connection_close(fd) ++ ++ processed_unbinds = inst.ds_access_log.match('.* UNBIND.*') ++ assert len(processed_unbinds) == unbind_count + 1, ( ++ "Large controlled UNBIND did not reach normal UNBIND processing" ++ ) ++ finally: ++ try: ++ conn.unbind_s() ++ except ldap.LDAPError: ++ pass ++ ++ ++def test_sasl_io_overflow(topo): ++ """Verify the SASL I/O layer does not heap-overflow on a padded UNBIND ++ ++ After a SASL bind with integrity protection (SSF > 0), the server pushes ++ the SASL I/O shim onto the connection. Send an oversized LDAP UNBIND whose ++ BER length includes attacker-controlled padding, directly on the raw socket ++ so it bypasses client-side SASL framing. The server must handle the ++ unencrypted UNBIND without copying past the caller buffer in sasl_io_recv. ++ ++ Requires an ASAN build so a heap-buffer-overflow is reported instead of ++ silent memory corruption. ++ ++ :id: 8ef3ea18-2c61-494c-b813-7044d0276adb ++ :setup: Standalone Instance with ASAN enabled ++ :steps: ++ 1. Create a test user with a clear-text password ++ 2. Perform a SASL DIGEST-MD5 bind with SSF > 0 ++ 3. Send a padded UNBIND on the raw socket, bypassing SASL framing ++ 4. Read from the connection and verify the server did not crash ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success ++ 4. Server remains running and the connection can be used or closed cleanly ++ """ ++ ++ inst = topo.standalone ++ if not inst.has_asan(): ++ pytest.skip("ASAN is not enabled on this server") ++ ++ # For digest-md5 we need clear text password ++ inst.config.set('passwordStorageScheme', 'CLEAR') ++ ++ # Add a user ++ users = UserAccounts(topo.standalone, DEFAULT_SUFFIX) ++ user = users.create_test_user(uid=1) ++ user.set('userPassword', PASSWORD) ++ user = 'test_user_1' ++ ++ # Step 1: SASL DIGEST-MD5 bind (pushes SASL I/O layer on server) ++ conn, fd = do_sasl_bind_and_get_fd(inst.host, inst.port, user) ++ server_pid = inst.get_pid() ++ ++ # Brief pause to ensure server has processed bind response ++ time.sleep(0.5) ++ ++ # Step 2: Send padded UNBIND on raw socket (triggers overflow) ++ log.info("") ++ log.info("[*] Sending exploit payload...") ++ log.info(f"[*] The server's sasl_io_recv will memcpy {4096 + 7} bytes") ++ log.info(f"[*] into a buffer likely sized ~4096-8192 bytes (BER read buffer)") ++ log.info(f"[*] Overflow: ~{max(0, PADDED_UNBIND_SIZE - 4096)} bytes past buffer end") ++ log.info("") ++ ++ send_padded_unbind(fd) ++ ++ log.info("") ++ log.info("[*] Exploit sent. Check server status:") ++ log.info("[*] - PID change indicates crash (heap corruption -> SIGSEGV/SIGABRT)") ++ log.info("[*] - Error log may show ASAN heap-buffer-overflow if built with sanitizers") ++ log.info("[*] - With default (non-ASAN) build, crash may be delayed until next heap op") ++ log.info("") ++ ++ try: ++ wait_for_connection_close(fd) ++ finally: ++ try: ++ conn.unbind_s() ++ except ldap.LDAPError: ++ pass ++ ++ assert inst.status(), "Server crashed while processing padded UNBIND" ++ assert inst.get_pid() == server_pid, "Server restarted while processing padded UNBIND" ++ ++ # Check ASAN report ++ log.info("[*] Checking ASAN report") ++ overflow_detected = False ++ try: ++ overflow_detected = check_asan_report(inst, 'heap-buffer-overflow') ++ except ValueError as e: ++ log.info('No ASAN report found (expected when no overflow): %s', e) ++ ++ assert not overflow_detected, 'heap-buffer-overflow detected in ASAN report' ++ ++ log.info("[*] Test passed") ++ ++ ++if __name__ == '__main__': ++ # Run isolated ++ # -s for DEBUG mode ++ CURRENT_FILE = os.path.realpath(__file__) ++ pytest.main(["-s", CURRENT_FILE]) +diff --git a/ldap/servers/slapd/sasl_io.c b/ldap/servers/slapd/sasl_io.c +index 405c9186f..74641e8a9 100644 +--- a/ldap/servers/slapd/sasl_io.c ++++ b/ldap/servers/slapd/sasl_io.c +@@ -54,6 +54,7 @@ + const char *send_buffer; /* encrypted buffer to send to client */ + unsigned int send_size; /* size of the encrypted buffer */ + unsigned int send_offset; /* number of bytes sent so far */ ++ bool unencrypted_buffer_ready; + }; + + typedef PRFilePrivate sasl_io_private; +@@ -153,6 +154,34 @@ sasl_io_finished_packet(sasl_io_private *sp) + return (sp->encrypted_buffer_count && (sp->encrypted_buffer_offset == sp->encrypted_buffer_count)); + } + ++static PRInt32 ++sasl_io_drain_unencrypted_buffer(sasl_io_private *sp, void *buf, PRInt32 len) ++{ ++ uint32_t bytes_to_copy; ++ ++ if (len <= 0) { ++ return 0; ++ } ++ ++ PR_ASSERT(sp->unencrypted_buffer_ready); ++ PR_ASSERT(sp->encrypted_buffer_offset <= sp->encrypted_buffer_count); ++ ++ bytes_to_copy = sp->encrypted_buffer_count - sp->encrypted_buffer_offset; ++ if (bytes_to_copy > (uint32_t)len) { ++ bytes_to_copy = (uint32_t)len; ++ } ++ memcpy(buf, sp->encrypted_buffer + sp->encrypted_buffer_offset, bytes_to_copy); ++ sp->encrypted_buffer_offset += bytes_to_copy; ++ ++ if (sp->encrypted_buffer_offset == sp->encrypted_buffer_count) { ++ sp->encrypted_buffer_offset = 0; ++ sp->encrypted_buffer_count = 0; ++ sp->unencrypted_buffer_ready = false; ++ } ++ ++ return (PRInt32)bytes_to_copy; ++} ++ + static const char *const sasl_LayerName = "SASL"; + static PRDescIdentity sasl_LayerID; + static PRIOMethods sasl_IoMethods; +@@ -190,34 +219,33 @@ sasl_io_start_packet(PRFileDesc *fd, PRIntn flags, PRIntervalTime timeout, PRInt + + *err = 0; + debug_print_layers(fd); +- /* first we need the length bytes */ +- ret = PR_Recv(fd->lower, buffer, amount, flags, timeout); +- slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", +- "Read sasl packet length returned %d on connection %" PRIu64 "\n", +- ret, c->c_connid); +- if (ret <= 0) { +- *err = PR_GetError(); +- if (ret == 0) { +- slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", +- "Connection closed while reading sasl packet length on connection %" PRIu64 "\n", +- c->c_connid); +- } else { ++ /* First read enough bytes to distinguish an LDAP message from a SASL packet. */ ++ if (sp->encrypted_buffer_offset < sizeof(buffer)) { ++ amount = sizeof(buffer) - sp->encrypted_buffer_offset; ++ ret = PR_Recv(fd->lower, buffer, amount, flags, timeout); + slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", +- "Error reading sasl packet length on connection %" PRIu64 " %d:%s\n", +- c->c_connid, *err, slapd_pr_strerror(*err)); ++ "Read sasl packet length returned %d on connection %" PRIu64 "\n", ++ ret, c->c_connid); ++ if (ret <= 0) { ++ *err = PR_GetError(); ++ if (ret == 0) { ++ slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", ++ "Connection closed while reading sasl packet length on connection %" PRIu64 "\n", ++ c->c_connid); ++ } else { ++ slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", ++ "Error reading sasl packet length on connection %" PRIu64 " %d:%s\n", ++ c->c_connid, *err, slapd_pr_strerror(*err)); ++ } ++ return ret; + } +- return ret; +- } +- /* +- * Read the bytes and add them to sp->encrypted_buffer +- * - if offset < 7, tell caller we didn't read enough bytes yet +- * - if offset >= 7, decode the length and proceed. +- */ +- if ((ret + sp->encrypted_buffer_offset) > sp->encrypted_buffer_size) { +- sasl_io_resize_encrypted_buffer(sp, ret + sp->encrypted_buffer_offset); ++ if ((ret + sp->encrypted_buffer_offset) > sp->encrypted_buffer_size) { ++ sasl_io_resize_encrypted_buffer(sp, ret + sp->encrypted_buffer_offset); ++ } ++ memcpy(sp->encrypted_buffer + sp->encrypted_buffer_offset, buffer, ret); ++ sp->encrypted_buffer_offset += ret; + } +- memcpy(sp->encrypted_buffer + sp->encrypted_buffer_offset, buffer, ret); +- sp->encrypted_buffer_offset += ret; ++ + if (sp->encrypted_buffer_offset < sizeof(buffer)) { + slapi_log_err(SLAPI_LOG_CONNS, + "sasl_io_start_packet", "Read only %d bytes of sasl packet " +@@ -242,9 +270,11 @@ sasl_io_start_packet(PRFileDesc *fd, PRIntn flags, PRIntervalTime timeout, PRInt + ber_len_t maxbersize = config_get_maxbersize(); + ber_len_t ber_len = 0; + ber_tag_t tag = 0; ++ uint32_t ber_header_len = 2; ++ uint32_t ber_packet_len; + +- slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", "conn=%" PRIu64 " fd=%d " +- "Sent an LDAP message that was not encrypted.\n", ++ slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", ++ "conn=%" PRIu64 " fd=%d Sent an LDAP message that was not encrypted.\n", + c->c_connid, + c->c_sd); + +@@ -265,11 +295,22 @@ sasl_io_start_packet(PRFileDesc *fd, PRIntn flags, PRIntervalTime timeout, PRInt + PR_SetError(PR_IO_ERROR, 0); + return PR_FAILURE; + } +- /* +- * Bump the ber length by 2 for the tag/length we skipped over when calculating the berval length. +- * We now have the total "packet" size, so we know exactly what is left to read in. +- */ +- ber_len += 2; ++ /* Include the tag and the complete short- or long-form BER length. */ ++ if (((unsigned char *)sp->encrypted_buffer)[1] & 0x80U) { ++ ber_header_len += ((unsigned char *)sp->encrypted_buffer)[1] & 0x7fU; ++ } ++ if (ber_len > (ber_len_t)(UINT32_MAX - ber_header_len)) { ++ slapi_log_err(SLAPI_LOG_ERR, "sasl_io_start_packet", ++ "conn=%" PRIu64 " fd=%d Incoming BER Element length cannot be represented.\n", ++ c->c_connid, c->c_sd); ++ PR_SetError(PR_BUFFER_OVERFLOW_ERROR, 0); ++ return PR_FAILURE; ++ } ++ ber_packet_len = (uint32_t)ber_len + ber_header_len; ++ if (ber_packet_len < sp->encrypted_buffer_offset) { ++ goto done; ++ } ++ sasl_io_resize_encrypted_buffer(sp, ber_packet_len); + + /* + * Read in the rest of the packet. +@@ -278,39 +319,35 @@ sasl_io_start_packet(PRFileDesc *fd, PRIntn flags, PRIntervalTime timeout, PRInt + * to the buffer. Once we have the complete LDAP packet we'll set it back to zero, + * and adjust the sp->encrypted_buffer_count. + */ +- while (sp->encrypted_buffer_offset < ber_len) { ++ while (sp->encrypted_buffer_offset < ber_packet_len) { ++ uint32_t bytes_to_read = ber_packet_len - sp->encrypted_buffer_offset; + unsigned char mybuf[SASL_IO_BUFFER_SIZE]; + +- ret = PR_Recv(fd->lower, mybuf, SASL_IO_BUFFER_SIZE, flags, timeout); +- if (ret == PR_WOULD_BLOCK_ERROR || (ret == 0 && sp->encrypted_buffer_offset < ber_len)) { +-/* +- * Need more data, go back and try to get more data from connection_read_operation() +- * We can return and continue to update sp->encrypted_buffer because we have +- * maintained the current size in encrypted_buffer_offset. +- */ +-#if defined(EWOULDBLOCK) +- errno = EWOULDBLOCK; +-#elif defined(EAGAIN) +- errno = EAGAIN; +-#endif +- PR_SetError(PR_WOULD_BLOCK_ERROR, errno); +- return PR_FAILURE; +- } else if (ret > 0) { ++ if (bytes_to_read > sizeof(mybuf)) { ++ bytes_to_read = sizeof(mybuf); ++ } ++ ++ ret = PR_Recv(fd->lower, mybuf, (PRInt32)bytes_to_read, flags, timeout); ++ if (ret > 0) { + slapi_log_err(SLAPI_LOG_CONNS, + "sasl_io_start_packet", + "Continued: read sasl packet length returned %d on connection %" PRIu64 "\n", + ret, c->c_connid); +- if ((ret + sp->encrypted_buffer_offset) > sp->encrypted_buffer_size) { +- sasl_io_resize_encrypted_buffer(sp, ret + sp->encrypted_buffer_offset); +- } + memcpy(sp->encrypted_buffer + sp->encrypted_buffer_offset, mybuf, ret); + sp->encrypted_buffer_offset += ret; +- } else if (ret < 0) { ++ } else if (ret == 0) { + *err = PR_GetError(); + slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", +- "Error reading sasl packet length on connection " +- "%" PRIu64 " %d:%s\n", +- c->c_connid, *err, slapd_pr_strerror(*err)); ++ "Connection closed while reading an LDAP packet on connection %" PRIu64 "\n", ++ c->c_connid); ++ return ret; ++ } else { ++ *err = PR_GetError(); ++ if (*err != PR_WOULD_BLOCK_ERROR) { ++ slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", ++ "Error reading LDAP packet on connection %" PRIu64 " %d:%s\n", ++ c->c_connid, *err, slapd_pr_strerror(*err)); ++ } + return ret; + } + } +@@ -345,6 +382,7 @@ sasl_io_start_packet(PRFileDesc *fd, PRIntn flags, PRIntervalTime timeout, PRInt + c->c_sd); + sp->encrypted_buffer_count = sp->encrypted_buffer_offset; + sp->encrypted_buffer_offset = 0; ++ sp->unencrypted_buffer_ready = true; + ber_free(ber, 1); + return SASL_IO_BUFFER_NOT_ENCRYPTED; + } +@@ -444,6 +482,10 @@ sasl_io_recv(PRFileDesc *fd, void *buf, PRInt32 len, PRIntn flags, PRIntervalTim + uint32_t bytes_in_buffer = 0; + int32_t err = 0; + ++ if (sp->unencrypted_buffer_ready) { ++ return sasl_io_drain_unencrypted_buffer(sp, buf, len); ++ } ++ + /* Do we have decrypted data buffered from 'before' ? */ + bytes_in_buffer = sp->decrypted_buffer_count - sp->decrypted_buffer_offset; + slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_recv", +@@ -459,11 +501,10 @@ sasl_io_recv(PRFileDesc *fd, void *buf, PRInt32 len, PRIntn flags, PRIntervalTim + ret = sasl_io_start_packet(fd, flags, timeout, &err); + if (SASL_IO_BUFFER_NOT_ENCRYPTED == ret) { + /* +- * Special case: we received unencrypted data that was actually +- * an unbind. Copy it to the buffer and return its length. ++ * Special case: return the validated unencrypted UNBIND over as ++ * many calls as the caller's buffer requires. + */ +- memcpy(buf, sp->encrypted_buffer, sp->encrypted_buffer_count); +- return sp->encrypted_buffer_count; ++ return sasl_io_drain_unencrypted_buffer(sp, buf, len); + } + if (0 >= ret) { + /* timeout, connection closed, or error */ +-- +2.55.0 + diff --git a/0019-Issue-7284-Creating-local-password-policy-succeeds-w.patch b/0019-Issue-7284-Creating-local-password-policy-succeeds-w.patch new file mode 100644 index 0000000..6bc2e65 --- /dev/null +++ b/0019-Issue-7284-Creating-local-password-policy-succeeds-w.patch @@ -0,0 +1,50 @@ +From f37eb3fb87a3955ab1a273a2951e5b12a1c46c83 Mon Sep 17 00:00:00 2001 +From: James Chapman +Date: Thu, 26 Feb 2026 12:55:47 +0000 +Subject: [PATCH] Issue 7284 - Creating local password policy succeeds with + incorrect passwordInHistory value (#7285) + +Description: +attr_check_minmax used strtol(value, NULL, 0), which silently converted +invalid strings to 0, passing subsequent range checks. + +Fix: +Add checks for NULL or empty values and uses strtol with endptr to +validate int input before range checks. + +Fixes: https://github.com/389ds/389-ds-base/issues/7284 + +Reviewed by: @vashirov, @tbordaz (Thank you) +--- + ldap/servers/slapd/attr.c | 14 +++++++++++++- + 1 file changed, 13 insertions(+), 1 deletion(-) + +diff --git a/ldap/servers/slapd/attr.c b/ldap/servers/slapd/attr.c +index f5ea22eb5..ca2eb62c3 100644 +--- a/ldap/servers/slapd/attr.c ++++ b/ldap/servers/slapd/attr.c +@@ -932,8 +932,20 @@ attr_check_minmax(const char *attr_name, char *value, long minval, long maxval, + { + int retVal = LDAP_SUCCESS; + long val; ++ char *endptr = NULL; ++ ++ if (!value || *value == '\0') { ++ slapi_create_errormsg(errorbuf, ebuflen, "%s: attr value is NULL.", attr_name); ++ return LDAP_CONSTRAINT_VIOLATION; ++ } ++ ++ errno = 0; ++ val = strtol(value, &endptr, 0); ++ if (endptr == value || *endptr != '\0' || errno != 0) { ++ slapi_create_errormsg(errorbuf, ebuflen, "%s: invalid value \"%s\".", attr_name, value); ++ return LDAP_CONSTRAINT_VIOLATION; ++ } + +- val = strtol(value, NULL, 0); + if ((minval != -1 ? (val < minval ? 1 : 0) : 0) || + (maxval != -1 ? (val > maxval ? 1 : 0) : 0)) { + slapi_create_errormsg(errorbuf, ebuflen, "%s: invalid value \"%s\".", attr_name, value); +-- +2.55.0 + diff --git a/0020-Issue-7284-CI-Fix-test_grace_limit_section-after-pwp.patch b/0020-Issue-7284-CI-Fix-test_grace_limit_section-after-pwp.patch new file mode 100644 index 0000000..495b274 --- /dev/null +++ b/0020-Issue-7284-CI-Fix-test_grace_limit_section-after-pwp.patch @@ -0,0 +1,39 @@ +From e0f7cd98f9cc80ac2b7c3eed0f663c1c25de0e3f Mon Sep 17 00:00:00 2001 +From: Akshay Adhikari +Date: Thu, 26 Mar 2026 21:37:11 +0530 +Subject: [PATCH] Issue 7284 - CI - Fix test_grace_limit_section after pwpolicy + validation fix (#7357) + +Description: +The test replaced passwordGraceLimit with a space to remove it, which is +now correctly rejected after attr_check_minmax validation was tightened. +Use remove_all() to properly delete the attribute instead. + +Fixes: #7284 + +Reviewed by: progier389, droideck +--- + dirsrvtests/tests/suites/password/password_policy_test.py | 7 +++++-- + 1 file changed, 5 insertions(+), 2 deletions(-) + +diff --git a/dirsrvtests/tests/suites/password/password_policy_test.py b/dirsrvtests/tests/suites/password/password_policy_test.py +index 8622ea0cb..20e2fa973 100644 +--- a/dirsrvtests/tests/suites/password/password_policy_test.py ++++ b/dirsrvtests/tests/suites/password/password_policy_test.py +@@ -1427,8 +1427,11 @@ def test_grace_limit_section(topo, policy_setup, _fixture_for_grace_limit): + for att1, att2 in [('passwordGraceLimit', '10')]: + _do_transaction_for_pwp(topo, att1, att2) + # removing the passwordgracelimit attribute should make it default to 0 +- for att1, att2 in [('passwordGraceLimit', ' ')]: +- _do_transaction_for_pwp(topo, att1, att2) ++ pwp = PwPolicyManager(topo.standalone) ++ for dn in [f'uid=orla,ou=dirsec,{DEFAULT_SUFFIX}', ++ f'uid=joe,ou=people,{DEFAULT_SUFFIX}', ++ f'ou=people,{DEFAULT_SUFFIX}']: ++ pwp.get_pwpolicy_entry(dn).remove_all('passwordGraceLimit') + change_password_with_admin(topo, [ + ('uid=orla,ou=dirsec', '000rLb1'), + ('uid=joe,ou=people', '00J0e1'), +-- +2.55.0 + diff --git a/0021-Issue-7284-Automated-test-for-creating-local-passwor.patch b/0021-Issue-7284-Automated-test-for-creating-local-passwor.patch new file mode 100644 index 0000000..28f7de4 --- /dev/null +++ b/0021-Issue-7284-Automated-test-for-creating-local-passwor.patch @@ -0,0 +1,91 @@ +From e362965090ae7687318ce94345e5bf7278073051 Mon Sep 17 00:00:00 2001 +From: Lenka Doudova +Date: Wed, 8 Jul 2026 14:39:38 +0200 +Subject: [PATCH] Issue 7284 - Automated test for creating local password + policy with incorrect passwordInHistory value (#7608) + +Description: +Adding automated test for creating local password policy with incorrect passwordInHistory value + +Relates: #7284 +Author: Lenka Doudova +Assisted by: Cursor +Reviewer: @progier389 +--- + .../suites/password/password_policy_test.py | 60 +++++++++++++++++++ + 1 file changed, 60 insertions(+) + +diff --git a/dirsrvtests/tests/suites/password/password_policy_test.py b/dirsrvtests/tests/suites/password/password_policy_test.py +index 20e2fa973..709e30b1c 100644 +--- a/dirsrvtests/tests/suites/password/password_policy_test.py ++++ b/dirsrvtests/tests/suites/password/password_policy_test.py +@@ -1563,6 +1563,66 @@ def test_additional_corner_cases(topo, policy_setup, _fixture_for_additional_cas + ]) + + ++@pytest.mark.parametrize('value,result', ++ [('0', ldap.SUCCESS), ++ ('24', ldap.SUCCESS), ++ pytest.param('-1', ldap.CONSTRAINT_VIOLATION, marks=pytest.mark.xfail(reason='https://github.com/389ds/389-ds-base/issues/7284')), ++ pytest.param('30', ldap.CONSTRAINT_VIOLATION, marks=pytest.mark.xfail(reason='https://github.com/389ds/389-ds-base/issues/7284')), ++ pytest.param('a', ldap.CONSTRAINT_VIOLATION, marks=pytest.mark.xfail(reason='https://github.com/389ds/389-ds-base/issues/7284'))]) ++def test_create_local_pwp_with_passwordInHistory(topo, value, result): ++ """Verify local password policy passwordInHistory accepts only values 0-24 ++ ++ :id: e7c4a1b2-3d5f-4a8e-9c1b-2f6e8d4a7b03 ++ :parametrized: yes ++ :setup: Standalone instance ++ :steps: ++ 1. Enable nsslapd-pwpolicy-local ++ 2. Create a dedicated OU under the default suffix ++ 3. Create a subtree local password policy with passwordInHistory set to ++ value using PwPolicyManager.create_subtree_policy ++ 4. For successful creation, verify passwordInHistory on the policy entry ++ 5. Delete the local password policy and OU ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success for value 0 or 24; CONSTRAINT_VIOLATION for -1, 30, or a ++ (invalid cases marked xfail) ++ 4. passwordInHistory matches value when creation succeeds ++ 5. Success ++ """ ++ inst = topo.standalone ++ inst.config.replace('nsslapd-pwpolicy-local', 'on') ++ ++ ous = OrganizationalUnits(inst, DEFAULT_SUFFIX) ++ ou = ous.create(properties={'ou': f'pwpinhist{value}'}) ++ ++ pwp = PwPolicyManager(inst) ++ ++ try: ++ if result == ldap.SUCCESS: ++ policy_entry = pwp.create_subtree_policy(ou.dn, {'passwordInHistory': value}) ++ assert policy_entry.get_attr_val_utf8('passwordInHistory') == value ++ else: ++ with pytest.raises(result): ++ pwp.create_subtree_policy(ou.dn, {'passwordInHistory': value}) ++ except ldap.LDAPError: ++ raise ++ finally: ++ try: ++ pwp.delete_local_policy(ou.dn) ++ except ValueError: ++ container = nsContainer(inst, f'cn=nsPwPolicyContainer,{ou.dn}') ++ try: ++ if container.exists(): ++ container.delete() ++ except ldap.LDAPError: ++ pass ++ try: ++ ou.delete() ++ except ldap.LDAPError: ++ pass ++ ++ + def test_get_pwpolicy_cn_with_quotes(topology_m1, policy_qoutes_setup): + """Test that that we can get pwpolicy when + cn attr includes quotes +-- +2.55.0 + diff --git a/0022-Issue-7284-Creating-local-password-policy-succeeds-w.patch b/0022-Issue-7284-Creating-local-password-policy-succeeds-w.patch new file mode 100644 index 0000000..066361b --- /dev/null +++ b/0022-Issue-7284-Creating-local-password-policy-succeeds-w.patch @@ -0,0 +1,319 @@ +From e2f152496052de949ffb727c354b00b4956d9aed Mon Sep 17 00:00:00 2001 +From: James Chapman +Date: Mon, 27 Jul 2026 09:58:51 +0100 +Subject: [PATCH] Issue 7284 - Creating local password policy succeeds with + incorrect passwordInHistory value (#7662) + +Description: +Creating a local password policy accepts invalid passwordrInHistory values. +Updating an existing policy via dsconf localpwp set correctly rejects the +same values with Constraint violation. + +Fine grained password policy validation ran only on the modify path. Local +policy create uses the add path, which wasnt updated, so invalid values were +accepted. + +Fix: +Add fine grained password policy attribute validation to the ADD path, +sharing the same checks used by MODIFY. + +Fixes: https://github.com/389ds/389-ds-base/issues/7284 + +Reviewed by: @mreynolds389 (Thank you) +--- + .../suites/password/password_policy_test.py | 6 +- + ldap/servers/slapd/add.c | 8 ++ + ldap/servers/slapd/modify.c | 70 +--------- + ldap/servers/slapd/pw.c | 123 ++++++++++++++++++ + ldap/servers/slapd/pw.h | 2 + + 5 files changed, 142 insertions(+), 67 deletions(-) + +diff --git a/dirsrvtests/tests/suites/password/password_policy_test.py b/dirsrvtests/tests/suites/password/password_policy_test.py +index 709e30b1c..4c4d2f6e4 100644 +--- a/dirsrvtests/tests/suites/password/password_policy_test.py ++++ b/dirsrvtests/tests/suites/password/password_policy_test.py +@@ -1566,9 +1566,9 @@ def test_additional_corner_cases(topo, policy_setup, _fixture_for_additional_cas + @pytest.mark.parametrize('value,result', + [('0', ldap.SUCCESS), + ('24', ldap.SUCCESS), +- pytest.param('-1', ldap.CONSTRAINT_VIOLATION, marks=pytest.mark.xfail(reason='https://github.com/389ds/389-ds-base/issues/7284')), +- pytest.param('30', ldap.CONSTRAINT_VIOLATION, marks=pytest.mark.xfail(reason='https://github.com/389ds/389-ds-base/issues/7284')), +- pytest.param('a', ldap.CONSTRAINT_VIOLATION, marks=pytest.mark.xfail(reason='https://github.com/389ds/389-ds-base/issues/7284'))]) ++ ('-1', ldap.CONSTRAINT_VIOLATION), ++ ('30', ldap.CONSTRAINT_VIOLATION), ++ ('a', ldap.CONSTRAINT_VIOLATION)]) + def test_create_local_pwp_with_passwordInHistory(topo, value, result): + """Verify local password policy passwordInHistory accepts only values 0-24 + +diff --git a/ldap/servers/slapd/add.c b/ldap/servers/slapd/add.c +index 2f0d57ffa..209048333 100644 +--- a/ldap/servers/slapd/add.c ++++ b/ldap/servers/slapd/add.c +@@ -730,6 +730,14 @@ op_shared_add(Slapi_PBlock *pb) + } + /* expand objectClass values to reflect the inheritance hierarchy */ + slapi_schema_expand_objectclasses(e); ++ ++ /* Validate password policy attrs */ ++ if (!internal_op) { ++ if ((err = check_pw_policy_attrs(e, NULL, errorbuf, sizeof(errorbuf))) != LDAP_SUCCESS) { ++ send_ldap_result(pb, err, NULL, errorbuf, 0, NULL); ++ goto done; ++ } ++ } + } + + /* +diff --git a/ldap/servers/slapd/modify.c b/ldap/servers/slapd/modify.c +index be93e0cd6..152eb5e9f 100644 +--- a/ldap/servers/slapd/modify.c ++++ b/ldap/servers/slapd/modify.c +@@ -66,34 +66,6 @@ mod_op_image(int op) + } + #endif + +-/* an AttrCheckFunc function should return an LDAP result code (LDAP_SUCCESS if all goes well). */ +-typedef int (*AttrCheckFunc)(const char *attr_name, char *value, long minval, long maxval, char *errorbuf, size_t ebuflen); +- +-static struct attr_value_check +-{ +- const char *attr_name; /* the name of the attribute */ +- AttrCheckFunc checkfunc; +- long minval; +- long maxval; +-} AttrValueCheckList[] = { +- {CONFIG_PW_SYNTAX_ATTRIBUTE, attr_check_onoff, 0, 0}, +- {CONFIG_PW_CHANGE_ATTRIBUTE, attr_check_onoff, 0, 0}, +- {CONFIG_PW_LOCKOUT_ATTRIBUTE, attr_check_onoff, 0, 0}, +- {CONFIG_PW_MUSTCHANGE_ATTRIBUTE, attr_check_onoff, 0, 0}, +- {CONFIG_PW_EXP_ATTRIBUTE, attr_check_onoff, 0, 0}, +- {CONFIG_PW_UNLOCK_ATTRIBUTE, attr_check_onoff, 0, 0}, +- {CONFIG_PW_HISTORY_ATTRIBUTE, attr_check_onoff, 0, 0}, +- {CONFIG_PW_MINAGE_ATTRIBUTE, check_pw_duration_value, -1, -1}, +- {CONFIG_PW_WARNING_ATTRIBUTE, check_pw_duration_value, 0, -1}, +- {CONFIG_PW_MINLENGTH_ATTRIBUTE, attr_check_minmax, 2, 512}, +- {CONFIG_PW_MAXFAILURE_ATTRIBUTE, attr_check_minmax, 1, 32767}, +- {CONFIG_PW_INHISTORY_ATTRIBUTE, attr_check_minmax, 0, 24}, +- {CONFIG_PW_LOCKDURATION_ATTRIBUTE, check_pw_duration_value, -1, -1}, +- {CONFIG_PW_RESETFAILURECOUNT_ATTRIBUTE, check_pw_resetfailurecount_value, -1, -1}, +- {CONFIG_PW_GRACELIMIT_ATTRIBUTE, attr_check_minmax, 0, -1}, +- {CONFIG_PW_STORAGESCHEME_ATTRIBUTE, check_pw_storagescheme_value, -1, -1}, +- {CONFIG_PW_MAXAGE_ATTRIBUTE, check_pw_duration_value, -1, -1}}; +- + /* This function is called to process operation that come over external connections */ + void + do_modify(Slapi_PBlock *pb) +@@ -662,7 +634,6 @@ op_shared_modify(Slapi_PBlock *pb, int pw_change, char *old_pw) + int err; + LDAPMod *lc_mod = NULL; + struct slapdplugin *p = NULL; +- int numattr; + char *proxydn = NULL; + int proxy_err = LDAP_SUCCESS; + char *errtext = NULL; +@@ -772,42 +743,13 @@ op_shared_modify(Slapi_PBlock *pb, int pw_change, char *old_pw) + + slapi_pblock_set(pb, SLAPI_BACKEND, be); + +- /* The following section checks the valid values of fine-grained +- * password policy attributes. +- * 1. First, it checks if the entry has "passwordpolicy" objectclass. +- * 2. If yes, then if the mods contain any passwdpolicy specific attributes. +- * 3. If yes, then it invokes corrosponding checking function. +- */ ++ /* Validate password policy attrs */ + if (!repl_op && !internal_op && normdn && slapi_search_get_entry(&entry_pb, sdn, NULL, &e, NULL) == LDAP_SUCCESS) { +- Slapi_Value target; +- slapi_value_init(&target); +- slapi_value_set_string(&target, "passwordpolicy"); +- if ((slapi_entry_attr_has_syntax_value(e, "objectclass", &target)) == 1) { +- numattr = sizeof(AttrValueCheckList) / sizeof(AttrValueCheckList[0]); +- while (tmpmods && *tmpmods) { +- if ((*tmpmods)->mod_bvalues != NULL && +- !SLAPI_IS_MOD_DELETE((*tmpmods)->mod_op)) { +- for (size_t i = 0; i < numattr; i++) { +- if (slapi_attr_type_cmp((*tmpmods)->mod_type, +- AttrValueCheckList[i].attr_name, SLAPI_TYPE_CMP_SUBTYPE) == 0) { +- /* The below function call is good for +- * single-valued attrs only +- */ +- if ((err = AttrValueCheckList[i].checkfunc(AttrValueCheckList[i].attr_name, +- (*tmpmods)->mod_bvalues[0]->bv_val, AttrValueCheckList[i].minval, +- AttrValueCheckList[i].maxval, errorbuf, sizeof(errorbuf))) != LDAP_SUCCESS) { +- /* return error */ +- send_ldap_result(pb, err, NULL, errorbuf, 0, NULL); +- goto free_and_return; +- } +- } +- } +- } +- tmpmods++; +- } /* end of (while */ +- } /* end of if (found */ +- value_done(&target); +- } /* end of if (!repl_op */ ++ if ((err = check_pw_policy_attrs(e, tmpmods, errorbuf, sizeof(errorbuf))) != LDAP_SUCCESS) { ++ send_ldap_result(pb, err, NULL, errorbuf, 0, NULL); ++ goto free_and_return; ++ } ++ } + + /* can get lastmod only after backend is selected */ + slapi_pblock_get(pb, SLAPI_BE_LASTMOD, &lastmod); +diff --git a/ldap/servers/slapd/pw.c b/ldap/servers/slapd/pw.c +index e81f10920..07e1e5fe7 100644 +--- a/ldap/servers/slapd/pw.c ++++ b/ldap/servers/slapd/pw.c +@@ -79,6 +79,7 @@ + */ + + #include ++#include + #include + #include + #include +@@ -2691,6 +2692,128 @@ check_pw_storagescheme_value(const char *attr_name __attribute__((unused)), char + + return retVal; + } ++ ++ /* pwpolicy_attr_check_fn function should return an LDAP result code (LDAP_SUCCESS if all goes well), shared by ADD and MODIFY */ ++typedef int (*pwpolicy_attr_check_fn)(const char *attr_name, char *value, long minval, long maxval, char *errorbuf, size_t ebuflen); ++ ++static const struct pwpolicy_attr_value_check ++{ ++ const char *attr_name; ++ pwpolicy_attr_check_fn checkfunc; ++ long minval; ++ long maxval; ++} pwpolicy_attr_value_checklist[] = { ++ {CONFIG_PW_SYNTAX_ATTRIBUTE, attr_check_onoff, 0, 0}, ++ {CONFIG_PW_CHANGE_ATTRIBUTE, attr_check_onoff, 0, 0}, ++ {CONFIG_PW_LOCKOUT_ATTRIBUTE, attr_check_onoff, 0, 0}, ++ {CONFIG_PW_MUSTCHANGE_ATTRIBUTE, attr_check_onoff, 0, 0}, ++ {CONFIG_PW_EXP_ATTRIBUTE, attr_check_onoff, 0, 0}, ++ {CONFIG_PW_UNLOCK_ATTRIBUTE, attr_check_onoff, 0, 0}, ++ {CONFIG_PW_HISTORY_ATTRIBUTE, attr_check_onoff, 0, 0}, ++ {CONFIG_PW_MINAGE_ATTRIBUTE, check_pw_duration_value, -1, -1}, ++ {CONFIG_PW_WARNING_ATTRIBUTE, check_pw_duration_value, 0, -1}, ++ {CONFIG_PW_MINLENGTH_ATTRIBUTE, attr_check_minmax, 2, 512}, ++ {CONFIG_PW_MAXFAILURE_ATTRIBUTE, attr_check_minmax, 1, 32767}, ++ {CONFIG_PW_INHISTORY_ATTRIBUTE, attr_check_minmax, 0, 24}, ++ {CONFIG_PW_LOCKDURATION_ATTRIBUTE, check_pw_duration_value, -1, -1}, ++ {CONFIG_PW_RESETFAILURECOUNT_ATTRIBUTE, check_pw_resetfailurecount_value, -1, -1}, ++ {CONFIG_PW_GRACELIMIT_ATTRIBUTE, attr_check_minmax, 0, -1}, ++ {CONFIG_PW_STORAGESCHEME_ATTRIBUTE, check_pw_storagescheme_value, -1, -1}, ++ {CONFIG_PW_MAXAGE_ATTRIBUTE, check_pw_duration_value, -1, -1}}; ++ ++#define PWPOLICY_ATTR_CHECK_COUNT \ ++ (sizeof(pwpolicy_attr_value_checklist) / sizeof(pwpolicy_attr_value_checklist[0])) ++ ++/* Local password policies only. */ ++static bool ++entry_is_pwpolicy(Slapi_Entry *e) ++{ ++ Slapi_Value target; ++ bool is_pwp; ++ ++ if (e == NULL) { ++ return false; ++ } ++ ++ slapi_value_init(&target); ++ slapi_value_set_string(&target, "passwordpolicy"); ++ is_pwp = (slapi_entry_attr_has_syntax_value(e, "objectclass", &target) == 1); ++ value_done(&target); ++ return is_pwp; ++} ++ ++/* Validate a single attr against the checklist */ ++static int ++check_pwpolicy_attr_value(const char *attr_type, char *value, char *errorbuf, size_t ebuflen) ++{ ++ size_t i; ++ ++ if (attr_type == NULL || value == NULL) { ++ return LDAP_SUCCESS; ++ } ++ ++ for (i = 0; i < PWPOLICY_ATTR_CHECK_COUNT; i++) { ++ const struct pwpolicy_attr_value_check *c = &pwpolicy_attr_value_checklist[i]; ++ ++ if (slapi_attr_type_cmp(attr_type, c->attr_name, SLAPI_TYPE_CMP_SUBTYPE) == 0) { ++ return c->checkfunc(c->attr_name, value, c->minval, c->maxval, errorbuf, ebuflen); ++ } ++ } ++ ++ return LDAP_SUCCESS; ++} ++ ++/* Passwordpolicy attr validation for ADD and MODIFY */ ++int ++check_pw_policy_attrs(Slapi_Entry *e, LDAPMod **mods, char *errorbuf, size_t ebuflen) ++{ ++ if (!entry_is_pwpolicy(e)) { ++ return LDAP_SUCCESS; ++ } ++ ++ /* Modify */ ++ if (mods != NULL) { ++ for (; *mods != NULL; mods++) { ++ int err; ++ ++ if ((*mods)->mod_bvalues == NULL || SLAPI_IS_MOD_DELETE((*mods)->mod_op)) { ++ continue; ++ } ++ err = check_pwpolicy_attr_value((*mods)->mod_type, ++ (*mods)->mod_bvalues[0]->bv_val, ++ errorbuf, ebuflen); ++ if (err != LDAP_SUCCESS) { ++ return err; ++ } ++ } ++ return LDAP_SUCCESS; ++ } ++ ++ /* Add */ ++ { ++ Slapi_Attr *attr = NULL; ++ char *type = NULL; ++ ++ for (slapi_entry_first_attr(e, &attr); attr; ++ slapi_entry_next_attr(e, attr, &attr)) { ++ Slapi_Value *val = NULL; ++ int err; ++ ++ slapi_attr_get_type(attr, &type); ++ if (slapi_attr_first_value(attr, &val) == -1 || val == NULL) { ++ continue; ++ } ++ err = check_pwpolicy_attr_value(type, (char *)slapi_value_get_string(val), ++ errorbuf, ebuflen); ++ if (err != LDAP_SUCCESS) { ++ return err; ++ } ++ } ++ } ++ ++ return LDAP_SUCCESS; ++} ++ + /* Before bind operation, check if the bind_target_entry has not overpass TPR limits + * returns: + * 0: TPR limits not enforced or reached +diff --git a/ldap/servers/slapd/pw.h b/ldap/servers/slapd/pw.h +index b2fd7c784..a39978805 100644 +--- a/ldap/servers/slapd/pw.h ++++ b/ldap/servers/slapd/pw.h +@@ -40,6 +40,8 @@ void delete_passwdPolicy(struct passwordpolicyarray **pwpolicy); + int check_pw_duration_value(const char *attr_name, char *value, long minval, long maxval, char *errorbuf, size_t ebuflen); + int check_pw_resetfailurecount_value(const char *attr_name, char *value, long minval, long maxval, char *errorbuf, size_t ebuflen); + int check_pw_storagescheme_value(const char *attr_name, char *value, long minval, long maxval, char *errorbuf, size_t ebuflen); ++/* Passwordpolicy attr validation for ADD and MODIFY */ ++int check_pw_policy_attrs(Slapi_Entry *e, LDAPMod **mods, char *errorbuf, size_t ebuflen); + + int pw_is_pwp_admin(Slapi_PBlock *pb, struct passwordpolicyarray *pwp, int rootdn_flag); + #define PWP_ADMIN_OR_ROOTDN 0 +-- +2.55.0 + diff --git a/0023-Issue-7707-lib389-set-nsDS5ReplicaBindDNGroup-before.patch b/0023-Issue-7707-lib389-set-nsDS5ReplicaBindDNGroup-before.patch new file mode 100644 index 0000000..5ecd183 --- /dev/null +++ b/0023-Issue-7707-lib389-set-nsDS5ReplicaBindDNGroup-before.patch @@ -0,0 +1,94 @@ +From 3a461582c0e587b33196a3fd880cabde6a23ff9d Mon Sep 17 00:00:00 2001 +From: Masahiro Matsuya +Date: Wed, 19 Aug 2026 17:31:10 +0900 +Subject: [PATCH] Issue 7707 - lib389: set nsDS5ReplicaBindDNGroup before + ensure_agreement() in join_supplier/hub/consumer (#7708) + +Bug Description: +join_supplier(), join_hub(), and join_consumer() set nsDS5ReplicaBindDNGroup +on the consumer after calling ensure_agreement(). The supplier's replication +thread starts immediately when the agreement is created and may send a +startReplication request before the bind DN group is configured, causing +check_replica_auth() to reject it with LDAP_INSUFFICIENT_ACCESS and entering +a permanent 'requires administrator action' state with no retry. + +Fix Description: +Move nsDS5ReplicaBindDNGroup before ensure_agreement() in all three call sites +and add an explanatory comment to prevent future regressions. + +Fixes: https://github.com/389ds/389-ds-base/issues/7707 + +Author: Masahiro Matsuya + +Reviewed by: @tbordaz, @mreynolds389 + +Signed-off-by: Masahiro Matsuya +--- + src/lib389/lib389/replica.py | 24 +++++++++++++++--------- + 1 file changed, 15 insertions(+), 9 deletions(-) + +diff --git a/src/lib389/lib389/replica.py b/src/lib389/lib389/replica.py +index 78d6eb4eb..a3bea4a4b 100644 +--- a/src/lib389/lib389/replica.py ++++ b/src/lib389/lib389/replica.py +@@ -2195,14 +2195,16 @@ class ReplicationManager(object): + # to allow the tot_init to occur. + self._bootstrap_replica(from_r, to_r, to_instance) + ++ # Set bind DN group BEFORE creating agreements: the supplier's replication ++ # thread starts immediately on agreement creation and will fail auth permanently ++ # if nsDS5ReplicaBindDNGroup is not yet configured on the consumer. ++ to_r.set('nsDS5ReplicaBindDNGroup', repl_dn) ++ + # Now put in an agreement from to -> from + # both ends. + self.ensure_agreement(from_instance, to_instance) + self.ensure_agreement(to_instance, from_instance, init=True) + +- # Now fix our replica credentials from -> to +- to_r.set('nsDS5ReplicaBindDNGroup', repl_dn) +- + # Now finally test it ... + self.test_replication(from_instance, to_instance) + self.test_replication(to_instance, from_instance) +@@ -2252,13 +2254,15 @@ class ReplicationManager(object): + # to allow the tot_init to occur. + self._bootstrap_replica(from_r, to_r, to_instance) + ++ # Set bind DN group BEFORE creating agreements: the supplier's replication ++ # thread starts immediately on agreement creation and will fail auth permanently ++ # if nsDS5ReplicaBindDNGroup is not yet configured on the consumer. ++ to_r.set('nsDS5ReplicaBindDNGroup', repl_dn) ++ + # Now put in an agreement from to -> from + # both ends. + self.ensure_agreement(from_instance, to_instance) + +- # Now fix our replica credentials from -> to +- to_r.set('nsDS5ReplicaBindDNGroup', repl_dn) +- + # Now finally test it ... + self.test_replication(from_instance, to_instance) + # Done! +@@ -2306,13 +2310,15 @@ class ReplicationManager(object): + # to allow the tot_init to occur. + self._bootstrap_replica(from_r, to_r, to_instance) + ++ # Set bind DN group BEFORE creating agreements: the supplier's replication ++ # thread starts immediately on agreement creation and will fail auth permanently ++ # if nsDS5ReplicaBindDNGroup is not yet configured on the consumer. ++ to_r.set('nsDS5ReplicaBindDNGroup', repl_group.dn) ++ + # Now put in an agreement from to -> from + # both ends. + self.ensure_agreement(from_instance, to_instance) + +- # Now fix our replica credentials from -> to +- to_r.set('nsDS5ReplicaBindDNGroup', repl_group.dn) +- + # Now finally test it ... + # If from_instance replica isn't read-write (hub, probably), we will test it later + if from_r.get_attr_val_int('nsDS5ReplicaType') == 3: +-- +2.55.0 + diff --git a/389-ds-base.spec b/389-ds-base.spec index 3bbe9da..da31f47 100644 --- a/389-ds-base.spec +++ b/389-ds-base.spec @@ -47,7 +47,7 @@ ExcludeArch: i686 Summary: 389 Directory Server (base) Name: 389-ds-base Version: 2.9.0 -Release: 2%{?dist} +Release: 3%{?dist} License: GPL-3.0-or-later WITH GPL-3.0-389-ds-base-exception AND (Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT) AND (Apache-2.0 OR LGPL-2.1-or-later OR MIT) AND (Apache-2.0 OR MIT) AND (MIT OR Apache-2.0) AND Unicode-3.0 AND (MIT OR Unlicense) AND Apache-2.0 AND MIT AND MPL-2.0 AND Zlib URL: https://www.port389.org Conflicts: selinux-policy-base < 3.9.8 @@ -300,6 +300,16 @@ Patch: 0011-Issue-7198-Web-console-doesn-t-show-sub-suffix-when-.patc Patch: 0012-Issue-7593-Reject-invalid-SASL-packet-length-values-.patch Patch: 0013-Issue-7593-Fix-testimony-docstring-for-SASL-overflow.patch Patch: 0014-Issue-7558-Total-init-sends-the-suffix-entry-twice-7.patch +Patch: 0015-Issue-7200-repl-agmt-create-doesn-t-set-some-paramet.patch +Patch: 0016-Issue-7705-With-memberOfEntryScope-set-deferred-memb.patch +Patch: 0017-Issue-7711-Fix-typo-in-accountpolicy-login-history-s.patch +Patch: 0018-Issue-7658-Heap-Buffer-Overflow-in-sasl_io_recv-via-.patch +Patch: 0019-Issue-7284-Creating-local-password-policy-succeeds-w.patch +Patch: 0020-Issue-7284-CI-Fix-test_grace_limit_section-after-pwp.patch +Patch: 0021-Issue-7284-Automated-test-for-creating-local-passwor.patch +Patch: 0022-Issue-7284-Creating-local-password-policy-succeeds-w.patch +Patch: 0023-Issue-7707-lib389-set-nsDS5ReplicaBindDNGroup-before.patch + %description 389 Directory Server is an LDAPv3 compliant server. The base package includes @@ -749,6 +759,16 @@ exit 0 %endif %changelog +* Tue Aug 18 2026 Simon Pichugin - 2.9.0-3 +- Bump version to 2.9.0-3 +- Resolves: RHEL-243052 - IPA - segfault with libjemalloc.so.2 [rhel-9] +- Resolves: RHEL-221410 - large IdM host group, missing memberof host group, authentication failures, high traffic and contention +- Resolves: RHEL-243050 - repl-agmt create doesn't set some parameters [rhel-9] +- Resolves: RHEL-243053 - 389-ds accountpolicy.py attribute name typo in help message [rhel-9] +- Resolves: RHEL-244870 - Local password policies can be created with unallowed values [rhel-9] +- Resolves: RHEL-238566 - lib389: join_supplier() sets nsDS5ReplicaBindDNGroup after ensure_agreement(), causing replication auth race [rhel-9] +- Resolves: RHEL-182153 - CVE-2026-11610 389-ds-base: 389-ds-base: Heap buffer overflow in sasl_io_recv() via padded SASL UNBIND [rhel-9.9] + * Fri Jul 17 2026 Simon Pichugin - 2.9.0-2 - Bump version to 2.9.0-2 - Resolves: RHEL-88942 - LDAP healthcheck and ignoring entrydn index and associated config