- 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]
1164 lines
53 KiB
Diff
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
|
|
|