389-ds-base/0020-Issue-7468-RFE-HIBP-password-breach-validation-7492.patch
Simon Pichugin b065a40300 Bump version to 3.3.0-3
- Issue 6387 - Use make macro in the spec file
- Resolves: RHEL-80254 - [RFE] Provide a way to check for leaked passwords
- Resolves: RHEL-80257 - [RFE] Should be able to monitor the importing progress via the CLI
- Resolves: RHEL-149760 - IPA - segfault with libjemalloc.so.2
- Resolves: RHEL-153098 - [RFE] Enable USDT probes in production builds
- Resolves: RHEL-171355 - 389-ds accountpolicy.py attribute name typo in help message
- Resolves: RHEL-213991 - repl-agmt create doesn't set some parameters
- Resolves: RHEL-243048 - large IdM host group, missing memberof host group, authentication failures, high traffic and contention [rhel-10]
- Resolves: RHEL-129204 - Local password policies can be created with unallowed values
- Resolves: RHEL-238569 - lib389: join_supplier() sets nsDS5ReplicaBindDNGroup after ensure_agreement(), causing replication auth race [rhel-10]
- Resolves: RHEL-182157 - CVE-2026-11610 389-ds-base: 389-ds-base: Heap buffer overflow in sasl_io_recv() via padded SASL UNBIND [rhel-10.3]
2026-08-19 09:54:05 -07:00

1164 lines
53 KiB
Diff

From 5a77540309d6d2736e3f509bdec9727273cc608b Mon Sep 17 00:00:00 2001
From: James Chapman <jachapma@redhat.com>
Date: Tue, 11 Aug 2026 13:01:46 +0100
Subject: [PATCH] Issue 7468 - RFE - HIBP password breach validation (#7492)
Description:
Integrate the HIBP HTTP client into password policy. Adds config,
schema, and password validation logic to check passwords against
the HIBP breach database during password add and modify operations.
Depends on the HIBP client PR.
- Schema: passwordBreachCheck, passwordBreachDbUrl, passwordBreachDbTimeout
- Config: Config setters in libglobs.c
- Validation: Check passwords on add/modify for both admin and non-admin users
- rootDN: Also validates nsslapd-rootpw changes against breach database
Dependencies:
- Issue 7468 - RFE - Add HIBP HTTP client
Relates: https://github.com/389ds/389-ds-base/issues/7468
Reviewed by: @mreynolds389, @droideck (Thank you)
---
.../tests/suites/config/config_test.py | 95 +++++
.../suites/password/pwdPolicy_breach_test.py | 401 ++++++++++++++++++
ldap/schema/02common.ldif | 5 +-
ldap/servers/slapd/add.c | 44 ++
ldap/servers/slapd/hibp.h | 3 +
ldap/servers/slapd/hibp_client.c | 1 +
ldap/servers/slapd/libglobs.c | 141 ++++++
ldap/servers/slapd/main.c | 11 +
ldap/servers/slapd/modify.c | 116 ++++-
ldap/servers/slapd/passwd_extop.c | 27 ++
ldap/servers/slapd/proto-slap.h | 4 +
ldap/servers/slapd/pw.c | 34 ++
ldap/servers/slapd/slap.h | 3 +
13 files changed, 883 insertions(+), 2 deletions(-)
create mode 100644 dirsrvtests/tests/suites/password/pwdPolicy_breach_test.py
diff --git a/dirsrvtests/tests/suites/config/config_test.py b/dirsrvtests/tests/suites/config/config_test.py
index 7020f4758..499ca7fd8 100644
--- a/dirsrvtests/tests/suites/config/config_test.py
+++ b/dirsrvtests/tests/suites/config/config_test.py
@@ -1184,6 +1184,101 @@ def test_lmdb_autotuned_maxdbs(topology_m2, request):
+def test_password_breach_check_config(topo):
+ """Test passwordBreachCheck configuration attribute.
+
+ :id: 33f30832-2e08-4421-90aa-cb9455ff9831
+ :setup: Standalone instance
+ :steps:
+ 1. Verify passwordBreachCheck default value
+ 2. Verify setting passwordBreachCheck to 'on'
+ 3. Verify invalid value is rejected
+ :expectedresults:
+ 1. Success
+ 2. Success
+ 3. Success
+ """
+ inst = topo.standalone
+
+ # Verify passwordBreachCheck default value
+ value = inst.config.get_attr_val_utf8('passwordBreachCheck')
+ assert value.lower() == 'off'
+
+ # Verify setting passwordBreachCheck
+ try:
+ inst.config.set('passwordBreachCheck', 'on')
+ value = inst.config.get_attr_val_utf8('passwordBreachCheck')
+ assert value.lower() == 'on'
+ # Reset
+ inst.config.set('passwordBreachCheck', 'off')
+ value = inst.config.get_attr_val_utf8('passwordBreachCheck')
+ assert value.lower() == 'off'
+ except ldap.UNWILLING_TO_PERFORM:
+ # HIBP support not compiled in
+ pass
+
+ # Verify invalid value is rejected
+ with pytest.raises(ldap.OPERATIONS_ERROR):
+ inst.config.set('passwordBreachCheck', 'invalid')
+
+
+def test_password_breach_db_url_config(topo):
+ """Test passwordBreachDbUrl configuration attribute.
+
+ :id: 42c1a438-652f-4d91-802c-4ac3cdac6c4e
+ :setup: Standalone instance
+ :steps:
+ 1. Verify setting passwordBreachDbUrl to custom URL
+ 2. Verify clearing passwordBreachDbUrl
+ :expectedresults:
+ 1. Success
+ 2. Success
+ """
+ inst = topo.standalone
+
+ # Verify setting passwordBreachDbUrl to custom URL
+ custom_url = "https://my-hibp-server.example.com/range/"
+ inst.config.set('passwordBreachDbUrl', custom_url)
+ value = inst.config.get_attr_val_utf8('passwordBreachDbUrl')
+ assert value == custom_url
+
+ # Verify clearing passwordBreachDbUrl
+ inst.config.remove_all('passwordBreachDbUrl')
+ value = inst.config.get_attr_val_utf8('passwordBreachDbUrl')
+ assert not value
+
+
+def test_password_breach_db_timeout_config(topo):
+ """Test passwordBreachDbTimeout configuration attribute.
+
+ :id: 9f03d561-ae2e-4ab5-8c16-e2d842bc7c35
+ :setup: Standalone instance
+ :steps:
+ 1. Verify passwordBreachDbTimeout default value
+ 2. Verify setting passwordBreachDbTimeout to '30'
+ 3. Restore default timeout
+ :expectedresults:
+ 1. Success
+ 2. Success
+ 3. Success
+ """
+ inst = topo.standalone
+
+ # Verify passwordBreachDbTimeout default value
+ value = inst.config.get_attr_val_utf8('passwordBreachDbTimeout')
+ assert value is not None
+ default_timeout = int(value)
+ assert default_timeout > 0
+
+ # Verify setting passwordBreachDbTimeout to '30'
+ inst.config.set('passwordBreachDbTimeout', '30')
+ value = inst.config.get_attr_val_utf8('passwordBreachDbTimeout')
+ assert value == '30', f"Expected '30', got '{value}'"
+
+ # Restore default timeout
+ inst.config.set('passwordBreachDbTimeout', str(default_timeout))
+
+
if __name__ == '__main__':
# Run isolated
# -s for DEBUG mode
diff --git a/dirsrvtests/tests/suites/password/pwdPolicy_breach_test.py b/dirsrvtests/tests/suites/password/pwdPolicy_breach_test.py
new file mode 100644
index 000000000..c9161e3d1
--- /dev/null
+++ b/dirsrvtests/tests/suites/password/pwdPolicy_breach_test.py
@@ -0,0 +1,401 @@
+# --- 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 os
+import pytest
+import ldap
+from test389.topologies import topology_st
+from lib389._constants import DEFAULT_SUFFIX, PASSWORD, DN_DM
+from lib389.idm.user import UserAccounts
+from lib389.idm.domain import Domain
+
+HIBP_NETWORK_TESTS = os.getenv('HIBP_NETWORK_TESTS')
+USER_RDN = 'breachuser'
+USER_DN = f'uid={USER_RDN},ou=People,{DEFAULT_SUFFIX}'
+USER_SELF_MOD_ACI = '(targetattr="userpassword")(version 3.0; acl "pwp test"; allow (all) userdn="ldap:///self";)'
+
+pytestmark = [
+ pytest.mark.tier2,
+ pytest.mark.skipif(HIBP_NETWORK_TESTS is None, reason="HIBP tests require network access. Set HIBP_NETWORK_TESTS=1")
+]
+
+logging.getLogger(__name__).setLevel(logging.INFO)
+log = logging.getLogger(__name__)
+
+
+@pytest.fixture
+def hibp_enabled(topology_st):
+ """Skip test if HIBP feature is not compiled in."""
+ try:
+ topology_st.standalone.config.get_attr_val_utf8('passwordBreachCheck')
+ except ldap.NO_SUCH_ATTRIBUTE:
+ pytest.skip("HIBP feature not compiled in (requires --enable-hibp)")
+
+
+@pytest.fixture(scope="module")
+def setup_breach_policy(topology_st):
+ """Enable HIBP breach checking in password policy."""
+ inst = topology_st.standalone
+ inst.simple_bind_s(DN_DM, PASSWORD)
+
+ try:
+ inst.config.get_attr_val_utf8('passwordBreachCheck')
+ except ldap.NO_SUCH_ATTRIBUTE:
+ pytest.skip("HIBP feature not compiled in (requires --enable-hibp)")
+
+ log.info('Adding ACI to allow self-service password changes')
+ suffix = Domain(inst, DEFAULT_SUFFIX)
+ suffix.add('aci', USER_SELF_MOD_ACI)
+
+ log.info('Enabling HIBP password breach checking')
+ inst.config.set('passwordCheckSyntax', 'on')
+ inst.config.set('passwordChange', 'on')
+ inst.config.set('passwordBreachCheck', 'on')
+
+ yield inst
+
+ log.info('Disabling HIBP password breach checking')
+ inst.simple_bind_s(DN_DM, PASSWORD)
+ inst.config.set('passwordBreachCheck', 'off')
+
+
+@pytest.fixture(scope="function")
+def breach_user(topology_st):
+ """Create a test user for each test."""
+ inst = topology_st.standalone
+ inst.simple_bind_s(DN_DM, PASSWORD)
+ users = UserAccounts(inst, DEFAULT_SUFFIX)
+
+ user = users.create(properties={
+ 'uid': USER_RDN,
+ 'cn': USER_RDN,
+ 'sn': USER_RDN,
+ 'uidNumber': '3000',
+ 'gidNumber': '4000',
+ 'homeDirectory': f'/home/{USER_RDN}',
+ 'userPassword': PASSWORD
+ })
+
+ yield user
+
+ inst.simple_bind_s(DN_DM, PASSWORD)
+ try:
+ user.delete()
+ except ldap.NO_SUCH_OBJECT:
+ pass
+
+
+def test_breached_password_rejected(topology_st, setup_breach_policy, breach_user):
+ """Test that a known breached password is rejected.
+
+ :id: 0fd8610d-eaed-423d-b96e-253da63d7ac4
+ :customerscenario: True
+ :setup: Standalone instance with passwordBreachCheck enabled
+ :steps:
+ 1. Enable passwordBreachCheck
+ 2. Bind as the test user
+ 3. Attempt to set a known breached password ('password')
+ 4. Verify the operation is rejected with CONSTRAINT_VIOLATION
+ :expectedresults:
+ 1. Password policy is configured
+ 2. Bound as user
+ 3. Password change attempt is made
+ 4. Operation fails with CONSTRAINT_VIOLATION
+ """
+ inst = setup_breach_policy
+
+ log.info('Binding as test user for self-service password change')
+ inst.simple_bind_s(USER_DN, PASSWORD)
+
+ users = UserAccounts(inst, DEFAULT_SUFFIX)
+ user = users.get(USER_RDN)
+
+ log.info('Attempting to set known breached password "password"')
+ try:
+ user.reset_password('password')
+ log.fatal('Breached password was unexpectedly accepted')
+ assert False, 'Breached password should have been rejected'
+ except ldap.CONSTRAINT_VIOLATION as e:
+ log.info(f'Breached password correctly rejected: {e}')
+
+
+def test_safe_password_accepted(topology_st, setup_breach_policy, breach_user):
+ """Test that a safe password is accepted.
+
+ :id: 62f7c432-2c09-48e6-8642-44881b2ca80a
+ :customerscenario: True
+ :setup: Standalone instance with passwordBreachCheck enabled
+ :steps:
+ 1. Enable passwordBreachCheck
+ 2. Bind as the test user
+ 3. Set a unique, non-breached password
+ 4. Verify the operation succeeds
+ 5. Verify user can bind with new password
+ :expectedresults:
+ 1. Password policy is configured
+ 2. Bound as user
+ 3. Password change succeeds
+ 4. Operation completes without error
+ 5. User can bind with new password
+ """
+ inst = setup_breach_policy
+
+ log.info('Binding as test user for self-service password change')
+ inst.simple_bind_s(USER_DN, PASSWORD)
+
+ users = UserAccounts(inst, DEFAULT_SUFFIX)
+ user = users.get(USER_RDN)
+
+ safe_password = 'xK9#mQ2$vL7@nP4!wR8^tY1&zB5'
+ log.info('Setting safe password')
+ user.reset_password(safe_password)
+
+ log.info('Verifying user can bind with new password')
+ inst.simple_bind_s(USER_DN, safe_password)
+ log.info('Safe password correctly accepted')
+
+
+def test_breach_check_disabled(topology_st, hibp_enabled, breach_user):
+ """Test that breached passwords are allowed when check is disabled.
+
+ :id: eba14842-290c-4a84-a032-481c8ac6a623
+ :customerscenario: True
+ :setup: Standalone instance with passwordBreachCheck disabled
+ :steps:
+ 1. Disable passwordBreachCheck and passwordCheckSyntax
+ 2. Bind as the test user
+ 3. Set a known breached password
+ 4. Verify the operation succeeds
+ 5. Verify user can bind with breached password
+ :expectedresults:
+ 1. Password policy is configured
+ 2. Bound as user
+ 3. Password change succeeds
+ 4. Operation completes without error
+ 5. User can bind with breached password
+ """
+ inst = topology_st.standalone
+ inst.simple_bind_s(DN_DM, PASSWORD)
+
+ log.info('Ensuring passwordBreachCheck and passwordCheckSyntax are disabled')
+ inst.config.set('passwordBreachCheck', 'off')
+ inst.config.set('passwordCheckSyntax', 'off')
+
+ log.info('Adding ACI to allow self-service password changes')
+ suffix = Domain(inst, DEFAULT_SUFFIX)
+ try:
+ suffix.add('aci', USER_SELF_MOD_ACI)
+ except ldap.TYPE_OR_VALUE_EXISTS:
+ pass
+
+ log.info('Binding as test user')
+ inst.simple_bind_s(USER_DN, PASSWORD)
+
+ users = UserAccounts(inst, DEFAULT_SUFFIX)
+ user = users.get(USER_RDN)
+
+ log.info('Setting known breached password "password"')
+ user.reset_password('password')
+
+ log.info('Verifying user can bind with breached password')
+ inst.simple_bind_s(USER_DN, 'password')
+ log.info('Breached password correctly allowed when check is disabled')
+
+ log.info('Restoring password policy settings')
+ inst.simple_bind_s(DN_DM, PASSWORD)
+ inst.config.set('passwordBreachCheck', 'on')
+ inst.config.set('passwordCheckSyntax', 'on')
+
+
+def test_admin_can_set_breached_password(topology_st, setup_breach_policy, breach_user):
+ """Test that admin (Directory Manager) can set breached passwords.
+
+ :id: cef5588d-9c20-4ecd-b563-362925732db5
+ :customerscenario: True
+ :setup: Standalone instance with passwordBreachCheck enabled
+ :steps:
+ 1. Enable passwordBreachCheck
+ 2. Bind as Directory Manager
+ 3. Set a breached password for the user
+ 4. Verify the operation succeeds (admin bypass)
+ :expectedresults:
+ 1. Password policy is configured
+ 2. Bound as admin
+ 3. Password change succeeds
+ 4. Admin can set any password
+ """
+ inst = setup_breach_policy
+
+ log.info('Binding as Directory Manager')
+ inst.simple_bind_s(DN_DM, PASSWORD)
+
+ log.info('Admin setting breached password for user')
+ users = UserAccounts(inst, DEFAULT_SUFFIX)
+ user = users.get(USER_RDN)
+ user.reset_password('password')
+
+
+def test_user_add_with_breached_password(topology_st, setup_breach_policy):
+ """Test that admin can create a user with a breached password (admin bypass).
+
+ :id: df37ef3d-8bc5-4adb-9dbf-a4024ff6a2cd
+ :customerscenario: True
+ :setup: Standalone instance with passwordBreachCheck enabled
+ :steps:
+ 1. Enable passwordBreachCheck
+ 2. Bind as Directory Manager
+ 3. Create a new user with breached password
+ 4. Verify the operation succeeds (admin bypass)
+ 5. Delete the test user
+ :expectedresults:
+ 1. Password policy is configured
+ 2. Bound as admin
+ 3. User creation succeeds
+ 4. Admin bypass allows breached password
+ 5. Cleanup succeeds
+ """
+ inst = setup_breach_policy
+
+ log.info('Binding as Directory Manager')
+ inst.simple_bind_s(DN_DM, PASSWORD)
+
+ users = UserAccounts(inst, DEFAULT_SUFFIX)
+
+ log.info('Admin creating user with breached password')
+ user = users.create(properties={
+ 'uid': 'breachtest',
+ 'cn': 'breachtest',
+ 'sn': 'breachtest',
+ 'uidNumber': '3001',
+ 'gidNumber': '4000',
+ 'homeDirectory': '/home/breachtest',
+ 'userPassword': 'password'
+ })
+
+ user.delete()
+
+
+def test_direct_modify_breached_password(topology_st, setup_breach_policy, breach_user):
+ """Test HIBP check via direct LDAP modify operation.
+
+ :id: 7d2a832b-abd8-4682-85d9-860661e2c82a
+ :customerscenario: True
+ :setup: Standalone instance with passwordBreachCheck enabled
+ :steps:
+ 1. Enable passwordBreachCheck
+ 2. Bind as test user
+ 3. Use direct ldap modify_s to set breached password
+ 4. Verify operation is rejected with CONSTRAINT_VIOLATION
+ :expectedresults:
+ 1. Password policy is configured
+ 2. Bound as user
+ 3. Modify operation is attempted
+ 4. Operation fails with CONSTRAINT_VIOLATION
+ """
+ inst = setup_breach_policy
+
+ log.info('Binding as test user')
+ inst.simple_bind_s(USER_DN, PASSWORD)
+
+ log.info('Attempting direct LDAP modify with breached password')
+ try:
+ inst.modify_s(USER_DN, [(ldap.MOD_REPLACE, 'userPassword', b'password')])
+ assert False, 'Breached password should have been rejected via direct modify'
+ except ldap.CONSTRAINT_VIOLATION as e:
+ log.info(f'Direct modify correctly rejected breached password: {e}')
+
+
+def test_passwd_extop_breached_password(topology_st, setup_breach_policy, breach_user):
+ """Test HIBP check via Password Modify Extended Operation.
+
+ Note: passwd_s requires a secure connection, this test attempts the
+ operation and skips if TLS is not configured.
+
+ :id: 05e89150-0124-4656-b15f-4d9926dbed39
+ :customerscenario: True
+ :setup: Standalone instance with passwordBreachCheck enabled
+ :steps:
+ 1. Enable passwordBreachCheck
+ 2. Bind as test user
+ 3. Use passwd_s (Password Modify ExtOp) to set breached password
+ 4. Verify operation is rejected with CONSTRAINT_VIOLATION
+ :expectedresults:
+ 1. Password policy is configured
+ 2. Bound as user
+ 3. Password Modify ExtOp is attempted
+ 4. Operation fails with CONSTRAINT_VIOLATION (or skipped if TLS required)
+ """
+ inst = setup_breach_policy
+
+ log.info('Binding as test user')
+ inst.simple_bind_s(USER_DN, PASSWORD)
+
+ log.info('Attempting Password Modify ExtOp with breached password')
+ try:
+ inst.passwd_s(USER_DN, PASSWORD, 'password')
+ assert False, 'Breached password should have been rejected via passwd_s'
+ except ldap.CONSTRAINT_VIOLATION as e:
+ log.info(f'Password Modify ExtOp correctly rejected breached password: {e}')
+ except ldap.CONFIDENTIALITY_REQUIRED:
+ pytest.skip('Password Modify ExtOp requires secure connection - TLS not configured')
+
+
+def test_rootpw_breached_password(topology_st, hibp_enabled):
+ """Test HIBP check for root password changes.
+
+ :id: 5290ec50-a12f-451e-a96c-056ff5df3ccf
+ :customerscenario: True
+ :setup: Standalone instance with passwordBreachCheck enabled
+ :steps:
+ 1. Enable passwordBreachCheck
+ 2. Bind as Directory Manager
+ 3. Attempt to set nsslapd-rootpw to breached password
+ 4. Verify operation is rejected with CONSTRAINT_VIOLATION
+ 5. Verify setting non-breached rootpw succeeds
+ 6. Restore original root password
+ :expectedresults:
+ 1. Password policy is configured
+ 2. Bound as admin
+ 3. Root password change is attempted
+ 4. Operation fails with CONSTRAINT_VIOLATION
+ 5. Non-breached password succeeds
+ 6. Original password restored
+ """
+ inst = topology_st.standalone
+ inst.simple_bind_s(DN_DM, PASSWORD)
+
+ log.info('Enabling passwordBreachCheck')
+ inst.config.set('passwordBreachCheck', 'on')
+
+ log.info('Attempting to set root password to breached value')
+ try:
+ inst.config.set('nsslapd-rootpw', 'password')
+ assert False, 'Breached root password should have been rejected'
+ except ldap.CONSTRAINT_VIOLATION as e:
+ log.info(f'Root password breach check correctly rejected: {e}')
+
+ log.info('Setting root password to non-breached value')
+ safe_rootpw = 'zX9#kM2$wL7@nQ4!rT8^yB1&vC5'
+ inst.config.set('nsslapd-rootpw', safe_rootpw)
+ log.info('Non-breached root password accepted')
+
+ log.info('Rebinding with new root password')
+ inst.simple_bind_s(DN_DM, safe_rootpw)
+
+ log.info('Disabling passwordBreachCheck before restoring original password')
+ inst.config.set('passwordBreachCheck', 'off')
+
+ log.info('Restoring original root password')
+ inst.config.set('nsslapd-rootpw', PASSWORD)
+ inst.simple_bind_s(DN_DM, PASSWORD)
+
+
+if __name__ == '__main__':
+ CURRENT_FILE = os.path.realpath(__file__)
+ pytest.main(["-s", CURRENT_FILE])
diff --git a/ldap/schema/02common.ldif b/ldap/schema/02common.ldif
index 00c3c79af..175ed612c 100644
--- a/ldap/schema/02common.ldif
+++ b/ldap/schema/02common.ldif
@@ -77,6 +77,9 @@ attributeTypes: ( 2.16.840.1.113730.3.1.2349 NAME ( 'passwordDictCheck' 'pwdDict
attributeTypes: ( 2.16.840.1.113730.3.1.2350 NAME ( 'passwordDictPath' 'pwdDictPath' ) DESC '389 Directory Server password policy attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE X-ORIGIN '389 Directory Server' )
attributeTypes: ( 2.16.840.1.113730.3.1.2351 NAME ( 'passwordUserAttributes' 'pwdUserAttributes' ) DESC '389 Directory Server password policy attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE X-ORIGIN '389 Directory Server' )
attributeTypes: ( 2.16.840.1.113730.3.1.2352 NAME ( 'passwordBadWords' 'pwdBadWords' ) DESC '389 Directory Server password policy attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE X-ORIGIN '389 Directory Server' )
+attributeTypes: ( 2.16.840.1.113730.3.1.3024 NAME ( 'passwordBreachCheck' 'pwdBreachCheck' ) DESC '389 Directory Server password policy attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE X-ORIGIN '389 Directory Server' )
+attributeTypes: ( 2.16.840.1.113730.3.1.3025 NAME ( 'passwordBreachDbUrl' 'pwdBreachDbUrl' ) DESC '389 Directory Server password policy attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE X-ORIGIN '389 Directory Server' )
+attributeTypes: ( 2.16.840.1.113730.3.1.3026 NAME ( 'passwordBreachDbTimeout' 'pwdBreachDbTimeout' ) DESC '389 Directory Server password policy attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN '389 Directory Server' )
attributeTypes: ( 2.16.840.1.113730.3.1.2366 NAME 'pwdReset' DESC '389 Directory Server password policy attribute type' EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation X-ORIGIN '389 Directory Server' )
attributeTypes: ( 2.16.840.1.113730.3.1.2378 NAME 'pwdTPRReset' DESC '389 Directory Server password policy attribute type' EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation X-ORIGIN '389 Directory Server' )
attributeTypes: ( 2.16.840.1.113730.3.1.2379 NAME 'pwdTPRUseCount' DESC '389 Directory Server password policy attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE USAGE directoryOperation X-ORIGIN '389 Directory Server' )
@@ -168,7 +171,7 @@ objectClasses: ( 2.16.840.1.113730.3.2.7 NAME 'nsLicenseUser' DESC 'Netscape def
objectClasses: ( 2.16.840.1.113730.3.2.1 NAME 'changeLogEntry' DESC 'LDAP changelog objectclass' SUP top MUST ( targetdn $ changeTime $ changenumber $ changeType ) MAY ( changes $ newrdn $ deleteoldrdn $ newsuperior ) X-ORIGIN 'Changelog Internet Draft' )
objectClasses: ( 2.16.840.1.113730.3.2.6 NAME 'referral' DESC 'LDAP referrals objectclass' SUP top MAY ( ref ) X-ORIGIN 'LDAPv3 referrals Internet Draft' )
objectClasses: ( 2.16.840.1.113730.3.2.12 NAME 'passwordObject' DESC 'Netscape defined password policy objectclass' SUP top MAY ( pwdpolicysubentry $ passwordExpirationTime $ passwordExpWarned $ passwordRetryCount $ retryCountResetTime $ accountUnlockTime $ passwordHistory $ passwordAllowChangeTime $ passwordGraceUserTime $ pwdReset $ pwdTPRReset $ pwdTPRUseCount $ pwdTPRValidFrom $ pwdTPRExpireAt ) X-ORIGIN 'Netscape Directory Server' )
-objectClasses: ( 2.16.840.1.113730.3.2.13 NAME 'passwordPolicy' DESC 'Netscape defined password policy objectclass' SUP top MAY ( passwordMaxAge $ passwordExp $ passwordMinLength $ passwordKeepHistory $ passwordInHistory $ passwordChange $ passwordWarning $ passwordLockout $ passwordMaxFailure $ passwordResetDuration $ passwordUnlock $ passwordLockoutDuration $ passwordCheckSyntax $ passwordMustChange $ passwordStorageScheme $ passwordMinAge $ passwordResetFailureCount $ passwordGraceLimit $ passwordMinDigits $ passwordMinAlphas $ passwordMinUppers $ passwordMinLowers $ passwordMinSpecials $ passwordMin8bit $ passwordMaxRepeats $ passwordMinCategories $ passwordMinTokenLength $ passwordTrackUpdateTime $ passwordAdminDN $ passwordDictCheck $ passwordDictPath $ passwordPalindrome $ passwordMaxSequence $ passwordMaxClassChars $ passwordMaxSeqSets $ passwordBadWords $ passwordUserAttributes $ passwordSendExpiringTime $ passwordTPRMaxUse $ passwordTPRDelayExpireAt $ passwordTPRDelayValidFrom $ passwordAdminSkipInfoUpdate ) X-ORIGIN 'Netscape Directory Server' )
+objectClasses: ( 2.16.840.1.113730.3.2.13 NAME 'passwordPolicy' DESC 'Netscape defined password policy objectclass' SUP top MAY ( passwordMaxAge $ passwordExp $ passwordMinLength $ passwordKeepHistory $ passwordInHistory $ passwordChange $ passwordWarning $ passwordLockout $ passwordMaxFailure $ passwordResetDuration $ passwordUnlock $ passwordLockoutDuration $ passwordCheckSyntax $ passwordMustChange $ passwordStorageScheme $ passwordMinAge $ passwordResetFailureCount $ passwordGraceLimit $ passwordMinDigits $ passwordMinAlphas $ passwordMinUppers $ passwordMinLowers $ passwordMinSpecials $ passwordMin8bit $ passwordMaxRepeats $ passwordMinCategories $ passwordMinTokenLength $ passwordTrackUpdateTime $ passwordAdminDN $ passwordDictCheck $ passwordDictPath $ passwordPalindrome $ passwordMaxSequence $ passwordMaxClassChars $ passwordMaxSeqSets $ passwordBadWords $ passwordUserAttributes $ passwordSendExpiringTime $ passwordTPRMaxUse $ passwordTPRDelayExpireAt $ passwordTPRDelayValidFrom $ passwordAdminSkipInfoUpdate $ passwordBreachCheck $ passwordBreachDbUrl $ passwordBreachDbTimeout ) X-ORIGIN 'Netscape Directory Server' )
objectClasses: ( 2.16.840.1.113730.3.2.30 NAME 'glue' DESC 'Netscape defined objectclass' SUP top X-ORIGIN 'Netscape Directory Server' )
objectClasses: ( 2.16.840.1.113730.3.2.32 NAME 'netscapeMachineData' DESC 'Netscape defined objectclass' SUP top X-ORIGIN 'Netscape Directory Server' )
objectClasses: ( 2.16.840.1.113730.3.2.38 NAME 'vlvSearch' DESC 'Netscape defined objectclass' SUP top MUST ( cn $ vlvBase $ vlvScope $ vlvFilter ) MAY ( multiLineDescription ) X-ORIGIN 'Netscape Directory Server' )
diff --git a/ldap/servers/slapd/add.c b/ldap/servers/slapd/add.c
index 2a7d55084..cbba0ecd4 100644
--- a/ldap/servers/slapd/add.c
+++ b/ldap/servers/slapd/add.c
@@ -37,6 +37,9 @@
#include "slap.h"
#include "pratom.h"
#include "csngen.h"
+#ifdef ENABLE_HIBP
+#include "hibp.h"
+#endif
/* Forward declarations */
static int add_internal_pb(Slapi_PBlock *pb);
@@ -666,6 +669,47 @@ op_shared_add(Slapi_PBlock *pb)
* Check password syntax, unless this is a pwd admin/rootDN
*/
present_values = attr_get_present_values(attr);
+#ifdef ENABLE_HIBP
+ /* Check all passwords against breach database (admin bypass) */
+ if (!pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN) &&
+ pwpolicy->pw_check_breach) {
+ /* Cap cleartext password values to prevent worker pool exhaustion */
+ size_t cleartext_count = 0;
+ for (size_t i = 0; present_values[i] != NULL; i++) {
+ const char *pwd = slapi_value_get_string(present_values[i]);
+ if (pwd && !slapi_is_encoded((char *)pwd)) {
+ cleartext_count++;
+ }
+ }
+ if (cleartext_count > HIBP_MAX_PASSWORDS_PER_OP) {
+ slapi_log_err(SLAPI_LOG_ERR, "op_shared_add",
+ "Too many cleartext password values (%zu) for %s - max %d allowed\n",
+ cleartext_count, slapi_entry_get_dn_const(e), HIBP_MAX_PASSWORDS_PER_OP);
+ send_ldap_result(pb, LDAP_UNWILLING_TO_PERFORM, NULL,
+ "Too many password values in single operation", 0, NULL);
+ goto done;
+ }
+
+ for (size_t i = 0; present_values[i] != NULL; i++) {
+ const char *pwd = slapi_value_get_string(present_values[i]);
+ if (pwd && !slapi_is_encoded((char *)pwd)) {
+ int breach_count = hibp_check_password(pwd, pwpolicy);
+ if (breach_count > 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "op_shared_add",
+ "Password for %s found in breach database (%d occurrences)\n",
+ slapi_entry_get_dn_const(e), breach_count);
+ send_ldap_result(pb, LDAP_CONSTRAINT_VIOLATION, NULL,
+ "Password found in breach database", 0, NULL);
+ goto done;
+ } else if (breach_count < 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "op_shared_add",
+ "Failed to check password against breach database for %s\n",
+ slapi_entry_get_dn_const(e));
+ }
+ }
+ }
+ }
+#endif
if (!pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN) &&
check_pw_syntax(pb, slapi_entry_get_sdn_const(e),
present_values, NULL, e, 0) != 0) {
diff --git a/ldap/servers/slapd/hibp.h b/ldap/servers/slapd/hibp.h
index fcda0bd54..a38991f98 100644
--- a/ldap/servers/slapd/hibp.h
+++ b/ldap/servers/slapd/hibp.h
@@ -10,6 +10,9 @@
#include "slap.h"
+/* Maximum number of cleartext password values allowed per operation.*/
+#define HIBP_MAX_PASSWORDS_PER_OP 5
+
/*
* Function pointer for pluggable SHA-1 implementation
* Allows for different hash implementations (FIPS vs non FIPS)
diff --git a/ldap/servers/slapd/hibp_client.c b/ldap/servers/slapd/hibp_client.c
index 48295d60f..86568d4d1 100644
--- a/ldap/servers/slapd/hibp_client.c
+++ b/ldap/servers/slapd/hibp_client.c
@@ -358,6 +358,7 @@ hibp_query_api(const char *prefix, const char *api_url, HIBPResponse *response,
if (curl_easy_setopt(curl, CURLOPT_URL, url) != CURLE_OK ||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, hibp_write_callback) != CURLE_OK ||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, response) != CURLE_OK ||
+ curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS) != CURLE_OK ||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L) != CURLE_OK ||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L) != CURLE_OK) {
slapi_log_err(SLAPI_LOG_ERR, "hibp_query_api",
diff --git a/ldap/servers/slapd/libglobs.c b/ldap/servers/slapd/libglobs.c
index 1456ad21c..26374d4ec 100644
--- a/ldap/servers/slapd/libglobs.c
+++ b/ldap/servers/slapd/libglobs.c
@@ -128,6 +128,9 @@
#include <unistd.h>
#endif /* USE_SYSCONF */
#include "slap.h"
+#ifdef ENABLE_HIBP
+#include "hibp.h"
+#endif
#include "plhash.h"
#if defined(LINUX)
#include <malloc.h>
@@ -213,6 +216,7 @@ slapi_onoff_t init_pw_exp;
slapi_onoff_t init_pw_send_expiring;
slapi_onoff_t init_pw_palindrome;
slapi_onoff_t init_pw_dict_check;
+slapi_onoff_t init_pw_breach_check;
slapi_onoff_t init_allow_hashed_pw;
slapi_onoff_t init_pw_syntax;
slapi_onoff_t init_schemacheck;
@@ -584,6 +588,21 @@ static struct config_get_and_set
NULL, 0,
(void **)&global_slapdFrontendConfig.pw_policy.pw_bad_words,
CONFIG_STRING, NULL, "", NULL},
+ /* password breach check */
+ {CONFIG_PW_BREACH_CHECK_ATTRIBUTE, config_set_pw_breach_check,
+ NULL, 0,
+ (void **)&global_slapdFrontendConfig.pw_policy.pw_check_breach,
+ CONFIG_ON_OFF, NULL, &init_pw_breach_check, NULL},
+ /* password breach database URL */
+ {CONFIG_PW_BREACH_URL_ATTRIBUTE, config_set_pw_breach_url,
+ NULL, 0,
+ (void **)&global_slapdFrontendConfig.pw_policy.pw_breach_db_url,
+ CONFIG_STRING, NULL, "", NULL},
+ /* password breach database timeout */
+ {CONFIG_PW_BREACH_TIMEOUT_ATTRIBUTE, config_set_pw_breach_timeout,
+ NULL, 0,
+ (void **)&global_slapdFrontendConfig.pw_policy.pw_breach_db_timeout,
+ CONFIG_INT, NULL, "10", NULL},
/* password max sequence */
{CONFIG_PW_MAX_SEQ_ATTRIBUTE, config_set_pw_max_seq,
NULL, 0,
@@ -1781,6 +1800,7 @@ pwpolicy_fe_init_onoff(passwdPolicy *pw_policy)
init_pw_track_update_time = pw_policy->pw_track_update_time;
init_pw_palindrome = pw_policy->pw_palindrome;
init_pw_dict_check = pw_policy->pw_check_dict;
+ init_pw_breach_check = pw_policy->pw_check_breach;
}
void
@@ -3831,6 +3851,127 @@ config_set_pw_dict_path(const char *attrname, char *value, char *errorbuf, int a
return retVal;
}
+int32_t
+config_set_pw_breach_check(const char *attrname, char *value, char *errorbuf, int apply)
+{
+ int32_t retVal = LDAP_SUCCESS;
+ slapdFrontendConfig_t *slapdFrontendConfig = getFrontendConfig();
+
+#ifndef ENABLE_HIBP
+ if (value && strcasecmp(value, "on") == 0) {
+ slapi_create_errormsg(errorbuf, SLAPI_DSE_RETURNTEXT_SIZE,
+ "%s: HIBP breached password checking is not available. "
+ "Rebuild with --enable-hibp to enable this feature.", attrname);
+ slapi_log_err(SLAPI_LOG_ERR, "config_set_pw_breach_check",
+ "HIBP breached password checking is not available - "
+ "rebuild with --enable-hibp to enable this feature\n");
+ return LDAP_UNWILLING_TO_PERFORM;
+ }
+#endif
+
+ retVal = config_set_onoff(attrname,
+ value,
+ &(slapdFrontendConfig->pw_policy.pw_check_breach),
+ errorbuf,
+ apply);
+
+ return retVal;
+}
+
+int32_t
+config_set_pw_breach_url(const char *attrname, char *value, char *errorbuf, int apply)
+{
+ int32_t retVal = LDAP_SUCCESS;
+ slapdFrontendConfig_t *slapdFrontendConfig = getFrontendConfig();
+ size_t len;
+
+#ifndef ENABLE_HIBP
+ if (apply && value && strlen(value) > 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "config_set_pw_breach_url",
+ "HIBP breached password checking not enabled - passwordBreachDbUrl has no effect\n");
+ }
+#endif
+
+ if (config_value_is_null(attrname, value, errorbuf, 0)) {
+ value = NULL;
+ }
+
+ /* Validate URL if provided */
+ if (value && strlen(value) > 0) {
+ /* Require https:// endpoint for security */
+ if (strncasecmp(value, "https://", 8) != 0) {
+ slapi_create_errormsg(errorbuf, SLAPI_DSE_RETURNTEXT_SIZE,
+ "%s: URL must use https://", attrname);
+ return LDAP_UNWILLING_TO_PERFORM;
+ }
+ /* Require trailing slash for correct URL construction */
+ len = strlen(value);
+ if (value[len - 1] != '/') {
+ slapi_create_errormsg(errorbuf, SLAPI_DSE_RETURNTEXT_SIZE,
+ "%s: URL must end with a trailing slash (e.g., https://api.pwnedpasswords.com/range/)",
+ attrname);
+ return LDAP_UNWILLING_TO_PERFORM;
+ }
+ }
+
+ if (apply) {
+ CFG_LOCK_WRITE(slapdFrontendConfig);
+ slapi_ch_free_string(&slapdFrontendConfig->pw_policy.pw_breach_db_url);
+ slapdFrontendConfig->pw_policy.pw_breach_db_url = slapi_ch_strdup(value);
+ CFG_UNLOCK_WRITE(slapdFrontendConfig);
+ }
+ return retVal;
+}
+
+char *
+config_get_pw_breach_url(void)
+{
+ slapdFrontendConfig_t *slapdFrontendConfig = getFrontendConfig();
+ char *retVal;
+
+ CFG_LOCK_READ(slapdFrontendConfig);
+ retVal = slapi_ch_strdup(slapdFrontendConfig->pw_policy.pw_breach_db_url);
+ CFG_UNLOCK_READ(slapdFrontendConfig);
+
+ return retVal;
+}
+
+int32_t
+config_set_pw_breach_timeout(const char *attrname, char *value, char *errorbuf, int apply)
+{
+ int32_t retVal = LDAP_SUCCESS;
+ int32_t timeout;
+ char *endp = NULL;
+ slapdFrontendConfig_t *slapdFrontendConfig = getFrontendConfig();
+
+#ifndef ENABLE_HIBP
+ if (apply) {
+ slapi_log_err(SLAPI_LOG_WARNING, "config_set_pw_breach_timeout",
+ "HIBP breached password checking not enabled - passwordBreachDbTimeout has no effect\n");
+ }
+#endif
+
+ if (config_value_is_null(attrname, value, errorbuf, 0)) {
+ return LDAP_OPERATIONS_ERROR;
+ }
+
+ errno = 0;
+ timeout = strtol(value, &endp, 10);
+ if (*endp != '\0' || errno == ERANGE || timeout < 1 || timeout > 300) {
+ slapi_create_errormsg(errorbuf, SLAPI_DSE_RETURNTEXT_SIZE,
+ "%s: invalid value \"%s\". Must be between 1 and 300.",
+ attrname, value);
+ return LDAP_OPERATIONS_ERROR;
+ }
+
+ if (apply) {
+ CFG_LOCK_WRITE(slapdFrontendConfig);
+ slapdFrontendConfig->pw_policy.pw_breach_db_timeout = timeout;
+ CFG_UNLOCK_WRITE(slapdFrontendConfig);
+ }
+ return retVal;
+}
+
char **
config_get_pw_user_attrs_array(void)
{
diff --git a/ldap/servers/slapd/main.c b/ldap/servers/slapd/main.c
index c370588e5..a65883b63 100644
--- a/ldap/servers/slapd/main.c
+++ b/ldap/servers/slapd/main.c
@@ -52,6 +52,9 @@ union semun
#endif
#include "slap.h"
#include "slapi-plugin.h"
+#ifdef ENABLE_HIBP
+#include "hibp.h"
+#endif
#include "prinit.h"
#include "snmp_collator.h"
#include "fe.h" /* client_auth_init() */
@@ -1035,6 +1038,13 @@ main(int argc, char **argv)
pw_exp_init();
op_stat_init();
+#ifdef ENABLE_HIBP
+ /* Initialise breached password checking. */
+ if (hibp_init() != 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "main", "Failed to initialise breached password checks\n");
+ }
+#endif
+
plugin_print_lists();
plugin_startall(argc, argv, NULL /* specific plugin list */);
compute_plugins_started();
@@ -1079,6 +1089,7 @@ main(int argc, char **argv)
/* pw_init() needs to be here since it uses aci function calls. */
pw_init();
+
/* Initialize the sasl mapping code */
if (sasl_map_init()) {
slapi_log_err(SLAPI_LOG_CRIT, "main", "Failed to initialize sasl mapping code\n");
diff --git a/ldap/servers/slapd/modify.c b/ldap/servers/slapd/modify.c
index bcb746709..92a559e2c 100644
--- a/ldap/servers/slapd/modify.c
+++ b/ldap/servers/slapd/modify.c
@@ -36,6 +36,9 @@
#include <sys/socket.h>
#include "slap.h"
#include "pratom.h"
+#ifdef ENABLE_HIBP
+#include "hibp.h"
+#endif
#if defined(irix) || defined(aix)
#include <time.h>
#endif
@@ -1091,6 +1094,60 @@ op_shared_modify(Slapi_PBlock *pb, int pw_change, char *old_pw)
* but it will detect that the password is already hashed.
*/
slapi_pblock_get(pb, SLAPI_MODIFY_MODS, &mods);
+#ifdef ENABLE_HIBP
+ /* Check rootpw against breach database before hashing. Only check
+ * if the requestor is root, unauth users will be rejected by DSE ACL. */
+ int isroot = 0;
+ slapi_pblock_get(pb, SLAPI_REQUESTOR_ISROOT, &isroot);
+ slapdFrontendConfig_t *slapdFrontendConfig = getFrontendConfig();
+ if (isroot && slapdFrontendConfig->pw_policy.pw_check_breach) {
+ passwdPolicy rootpw_policy = {0};
+ rootpw_policy.pw_check_breach = LDAP_ON;
+ rootpw_policy.pw_breach_db_url = config_get_pw_breach_url();
+ rootpw_policy.pw_breach_db_timeout = slapdFrontendConfig->pw_policy.pw_breach_db_timeout;
+ for (size_t i = 0; mods && mods[i]; i++) {
+ if (strcasecmp(mods[i]->mod_type, CONFIG_ROOTPW_ATTRIBUTE) == 0 && mods[i]->mod_bvalues) {
+ /* Cap cleartext password values to prevent worker pool exhaustion */
+ size_t cleartext_count = 0;
+ for (size_t j = 0; mods[i]->mod_bvalues[j]; j++) {
+ char *val = mods[i]->mod_bvalues[j]->bv_val;
+ if (val && !slapi_is_encoded(val)) {
+ cleartext_count++;
+ }
+ }
+ if (cleartext_count > HIBP_MAX_PASSWORDS_PER_OP) {
+ slapi_log_err(SLAPI_LOG_ERR, "op_shared_modify",
+ "Too many cleartext rootpw values (%zu) - max %d allowed\n",
+ cleartext_count, HIBP_MAX_PASSWORDS_PER_OP);
+ slapi_ch_free_string(&rootpw_policy.pw_breach_db_url);
+ send_ldap_result(pb, LDAP_UNWILLING_TO_PERFORM, NULL,
+ "Too many password values in single operation", 0, NULL);
+ goto free_and_return;
+ }
+
+ for (size_t j = 0; mods[i]->mod_bvalues[j]; j++) {
+ char *val = mods[i]->mod_bvalues[j]->bv_val;
+ if (val && !slapi_is_encoded(val)) {
+ int breach_count = hibp_check_password(val, &rootpw_policy);
+ if (breach_count > 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "op_shared_modify",
+ "Rejecting rootDN password - found in breach database (%d occurrences)\n",
+ breach_count);
+ slapi_ch_free_string(&rootpw_policy.pw_breach_db_url);
+ send_ldap_result(pb, LDAP_CONSTRAINT_VIOLATION, NULL,
+ "Password found in breach database - choose a different password", 0, NULL);
+ goto free_and_return;
+ } else if (breach_count < 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "op_shared_modify",
+ "Failed to check rootDN password against breach database - allowing (fail-open)\n");
+ }
+ }
+ }
+ }
+ }
+ slapi_ch_free_string(&rootpw_policy.pw_breach_db_url);
+ }
+#endif
if (hash_rootpw(mods) != 0) {
send_ldap_result(pb, LDAP_UNWILLING_TO_PERFORM, NULL,
"Failed to hash root user's password", 0, NULL);
@@ -1329,6 +1386,63 @@ op_shared_allow_pw_change(Slapi_PBlock *pb, LDAPMod *mod, char **old_pw, Slapi_M
/* done with slapi entry e */
slapi_search_get_entry_done(&entry_pb);
+#ifdef ENABLE_HIBP
+ /* Check password against breach database after ACI validation (admin bypass) */
+ if (!SLAPI_IS_MOD_DELETE(mod->mod_op) && pwpolicy->pw_check_breach && mod->mod_bvalues) {
+ if (pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN)) {
+ slapi_log_err(SLAPI_LOG_DEBUG, "op_shared_allow_pw_change",
+ "Skipping breach check for %s - admin bypass\n", dn);
+ } else {
+ Slapi_Value **breach_vals = NULL;
+ valuearray_init_bervalarray(mod->mod_bvalues, &breach_vals);
+ if (breach_vals) {
+ /* Cap cleartext password values to prevent worker pool exhaustion. */
+ size_t cleartext_count = 0;
+ for (size_t i = 0; breach_vals[i] != NULL; i++) {
+ const char *pwd = slapi_value_get_string(breach_vals[i]);
+ if (pwd && !slapi_is_encoded((char *)pwd)) {
+ cleartext_count++;
+ }
+ }
+ if (cleartext_count > HIBP_MAX_PASSWORDS_PER_OP) {
+ slapi_log_err(SLAPI_LOG_ERR, "op_shared_allow_pw_change",
+ "Too many cleartext password values (%zu) for %s - max %d allowed\n",
+ cleartext_count, dn, HIBP_MAX_PASSWORDS_PER_OP);
+ send_ldap_result(pb, LDAP_UNWILLING_TO_PERFORM, NULL,
+ "Too many password values in single operation", 0, NULL);
+ valuearray_free(&breach_vals);
+ rc = -1;
+ goto done;
+ }
+
+ for (size_t i = 0; breach_vals[i] != NULL; i++) {
+ const char *pwd = slapi_value_get_string(breach_vals[i]);
+ if (pwd && !slapi_is_encoded((char *)pwd)) {
+ int breach_count = hibp_check_password(pwd, pwpolicy);
+ if (breach_count > 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "op_shared_allow_pw_change",
+ "Password for %s found in breach database (%d occurrences)\n",
+ dn, breach_count);
+ if (pwresponse_req == 1) {
+ slapi_pwpolicy_make_response_control(pb, -1, -1, LDAP_PWPOLICY_INVALIDPWDSYNTAX);
+ }
+ send_ldap_result(pb, LDAP_CONSTRAINT_VIOLATION, NULL,
+ "Password found in breach database - choose a different password", 0, NULL);
+ valuearray_free(&breach_vals);
+ rc = -1;
+ goto done;
+ } else if (breach_count < 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "op_shared_allow_pw_change",
+ "Failed to check password against breach database for %s\n", dn);
+ }
+ }
+ }
+ valuearray_free(&breach_vals);
+ }
+ }
+ }
+#endif
+
/*
* If this mod is being performed by a password administrator/rootDN,
* just return success.
@@ -1377,7 +1491,7 @@ op_shared_allow_pw_change(Slapi_PBlock *pb, LDAPMod *mod, char **old_pw, Slapi_M
}
} else if (pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN)) {
/* This is an internal operation, but we still need to check if this
- * is a password admin */
+ is a password admin */
if (!SLAPI_IS_MOD_DELETE(mod->mod_op) && pwpolicy->pw_history) {
/* Updating pw history, get the old password */
get_old_pw(pb, &sdn, old_pw);
diff --git a/ldap/servers/slapd/passwd_extop.c b/ldap/servers/slapd/passwd_extop.c
index 5f05cf74e..0cbdf004e 100644
--- a/ldap/servers/slapd/passwd_extop.c
+++ b/ldap/servers/slapd/passwd_extop.c
@@ -32,6 +32,9 @@
#include "slap.h"
#include "slapi-plugin.h"
#include "fe.h"
+#ifdef ENABLE_HIBP
+#include "hibp.h"
+#endif
/* Type of connection for this operation;*/
#define LDAP_EXTOP_PASSMOD_CONN_SECURE
@@ -811,6 +814,30 @@ parse_req_done:
* performing the modify operation. */
slapi_pblock_get(pb, SLAPI_REQCONTROLS, &req_controls);
+#ifdef ENABLE_HIBP
+ /* Check password against breach database (admin bypass) */
+ if (pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN)) {
+ slapi_log_err(SLAPI_LOG_DEBUG, "passwd_modify_extop",
+ "Skipping breach check for %s - admin bypass\n", dn);
+ } else if (pwpolicy->pw_check_breach && newPasswd && !slapi_is_encoded((char *)newPasswd)) {
+ int breach_count = hibp_check_password(newPasswd, pwpolicy);
+ if (breach_count > 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "passwd_modify_extop",
+ "Password for %s found in breach database (%d occurrences)\n",
+ dn, breach_count);
+ if (need_pwpolicy_ctrl) {
+ slapi_pwpolicy_make_response_control(pb, -1, -1, LDAP_PWPOLICY_INVALIDPWDSYNTAX);
+ }
+ errMesg = "Password found in breach database - choose a different password";
+ rc = LDAP_CONSTRAINT_VIOLATION;
+ goto free_and_return;
+ } else if (breach_count < 0) {
+ slapi_log_err(SLAPI_LOG_WARNING, "passwd_modify_extop",
+ "Failed to check password against breach database - allowing (fail-open)\n");
+ }
+ }
+#endif
+
/* Now we're ready to make actual password change */
ret = passwd_modify_userpassword(pb, targetEntry, newPasswd, req_controls, &resp_controls);
diff --git a/ldap/servers/slapd/proto-slap.h b/ldap/servers/slapd/proto-slap.h
index 0d0f98cc8..efbba6bca 100644
--- a/ldap/servers/slapd/proto-slap.h
+++ b/ldap/servers/slapd/proto-slap.h
@@ -318,6 +318,10 @@ char **config_get_pw_user_attrs_array(void);
int32_t config_set_pw_user_attrs(const char *attrname, char *value, char *errorbuf, int apply);
char **config_get_pw_bad_words_array(void);
int32_t config_set_pw_bad_words(const char *attrname, char *value, char *errorbuf, int apply);
+int32_t config_set_pw_breach_check(const char *attrname, char *value, char *errorbuf, int apply);
+int32_t config_set_pw_breach_url(const char *attrname, char *value, char *errorbuf, int apply);
+char *config_get_pw_breach_url(void);
+int32_t config_set_pw_breach_timeout(const char *attrname, char *value, char *errorbuf, int apply);
int32_t config_set_pw_max_seq_sets(const char *attrname, char *value, char *errorbuf, int apply);
int32_t config_set_pw_max_seq(const char *attrname, char *value, char *errorbuf, int apply);
int32_t config_set_pw_max_class_repeats(const char *attrname, char *value, char *errorbuf, int apply);
diff --git a/ldap/servers/slapd/pw.c b/ldap/servers/slapd/pw.c
index ccb7bfd68..19f50c2bf 100644
--- a/ldap/servers/slapd/pw.c
+++ b/ldap/servers/slapd/pw.c
@@ -2356,6 +2356,33 @@ new_passwdPolicy(Slapi_PBlock *pb, const char *dn)
pwdpolicy->pw_check_dict =
pw_boolean_str2value(slapi_value_get_string(*sval));
}
+ } else if (!strcasecmp(attr_name, "passwordBreachCheck")) {
+ if ((sval = attr_get_present_values(attr))) {
+ pwdpolicy->pw_check_breach =
+ pw_boolean_str2value(slapi_value_get_string(*sval));
+ }
+ } else if (!strcasecmp(attr_name, "passwordBreachDbUrl")) {
+ if ((sval = attr_get_present_values(attr))) {
+ const char *url = slapi_value_get_string(*sval);
+ size_t url_len = strlen(url);
+ /* Validate URL: require https:// and trailing slash */
+ if (url_len > 0 && strncasecmp(url, "https://", 8) != 0) {
+ slapi_log_err(SLAPI_LOG_ERR, "new_passwdPolicy",
+ "Invalid passwordBreachDbUrl in local policy %s: must use https://\n",
+ pwdpolicy->pw_local_dn);
+ } else if (url_len > 0 && url[url_len - 1] != '/') {
+ slapi_log_err(SLAPI_LOG_ERR, "new_passwdPolicy",
+ "Invalid passwordBreachDbUrl in local policy %s: must end with trailing slash\n",
+ pwdpolicy->pw_local_dn);
+ } else {
+ pwdpolicy->pw_breach_db_url = slapi_ch_strdup(url);
+ }
+ }
+ } else if (!strcasecmp(attr_name, "passwordBreachDbTimeout")) {
+ if ((sval = attr_get_present_values(attr))) {
+ pwdpolicy->pw_breach_db_timeout =
+ atoi(slapi_value_get_string(*sval));
+ }
} else if (!strcasecmp(attr_name, "passwordUserAttributes")) {
if ((sval = attr_get_present_values(attr))) {
char *attrs = slapi_ch_strdup(slapi_value_get_string(*sval));
@@ -2442,7 +2469,13 @@ new_passwdPolicy(Slapi_PBlock *pb, const char *dn)
pwdpolicy->pw_palindrome = g_pwdpolicy->pw_palindrome;
pwdpolicy->pw_check_dict = g_pwdpolicy->pw_check_dict;
pwdpolicy->pw_dict_path = g_pwdpolicy->pw_dict_path;
+ pwdpolicy->pw_check_breach = g_pwdpolicy->pw_check_breach;
+ slapi_ch_free_string(&pwdpolicy->pw_breach_db_url);
+ pwdpolicy->pw_breach_db_url = config_get_pw_breach_url();
+ pwdpolicy->pw_breach_db_timeout = g_pwdpolicy->pw_breach_db_timeout;
+ slapi_ch_array_free(pwdpolicy->pw_cmp_attrs_array);
pwdpolicy->pw_cmp_attrs_array = config_get_pw_user_attrs_array();
+ slapi_ch_array_free(pwdpolicy->pw_bad_words_array);
pwdpolicy->pw_bad_words_array = config_get_pw_bad_words_array();
pwdpolicy->pw_syntax = LDAP_ON; /* Need to enable it to apply the default values */
}
@@ -2495,6 +2528,7 @@ delete_passwdPolicy(passwdPolicy **pwpolicy)
slapi_ch_free_string(&(*(*pwpolicy)).pw_bad_words);
slapi_ch_array_free((*(*pwpolicy)).pw_cmp_attrs_array);
slapi_ch_free_string(&(*(*pwpolicy)).pw_cmp_attrs);
+ slapi_ch_free_string(&(*(*pwpolicy)).pw_breach_db_url);
}
slapi_ch_free_string(&(*(*pwpolicy)).pw_local_dn);
slapi_ch_free((void **)pwpolicy);
diff --git a/ldap/servers/slapd/slap.h b/ldap/servers/slapd/slap.h
index 0a209921c..387cfe32d 100644
--- a/ldap/servers/slapd/slap.h
+++ b/ldap/servers/slapd/slap.h
@@ -2339,6 +2339,9 @@ typedef struct _slapdEntryPoints
#define CONFIG_PW_DICT_PATH_ATTRIBUTE "passwordDictPath"
#define CONFIG_PW_USERATTRS_ATTRIBUTE "passwordUserAttributes"
#define CONFIG_PW_BAD_WORDS_ATTRIBUTE "passwordBadWords"
+#define CONFIG_PW_BREACH_CHECK_ATTRIBUTE "passwordBreachCheck"
+#define CONFIG_PW_BREACH_URL_ATTRIBUTE "passwordBreachDbUrl"
+#define CONFIG_PW_BREACH_TIMEOUT_ATTRIBUTE "passwordBreachDbTimeout"
#define CONFIG_PW_EXP_ATTRIBUTE "passwordExp"
#define CONFIG_PW_MAXAGE_ATTRIBUTE "passwordMaxAge"
#define CONFIG_PW_MINAGE_ATTRIBUTE "passwordMinAge"
--
2.55.0