From 544b3ce678c806c1f7935b07049d7ca4c4e4a15f Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 16:58:57 +0200 Subject: [PATCH] [PR #12123/49ca175f backport][stable-13] htpasswd: fix `hash_scheme` aliases and Apache-compatible bcrypt (#12156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit htpasswd: fix `hash_scheme` aliases and Apache-compatible bcrypt (#12123) * fix(htpasswd): support HtpasswdFile aliases and Apache-compatible bcrypt CryptContext does not recognise HtpasswdFile alias names such as portable, portable_apache_24, host_apache_24, causing a KeyError. In addition, building a CryptContext for bcrypt produced $2b$ hashes that Apache rejects (it only accepts $2y$/$2a$). Use htpasswd_context for schemes it already supports, fall back to htpasswd_context on KeyError for aliases, and import CryptContext from module_utils/_crypt.py instead of passlib directly. Fixes #6135 * feat(changelog): add fragment for PR 12123 * fix(_crypt): silence DeprecationWarning when importing stdlib crypt On Python 3.11/3.12, `import crypt` emits a DeprecationWarning that ansible-test sanity --test import treats as an error. Suppress it since the import is an intentional fallback when passlib is not available. * fix(htpasswd): fix sanity ignores and bcrypt version constraint - Revert _crypt.py DeprecationWarning suppression; add sanity ignore entries for htpasswd.py import-3.11/3.12 instead (mirrors existing entries for _crypt.py itself) - Pin bcrypt<4.2 in integration tests: bcrypt 4.2 removed __about__ which passlib 1.7.x uses, breaking passlib.apache import - Fix regex_search assertion to use 'is not none' for a boolean result - Add bcrypt version constraint note to module documentation * fix(htpasswd): handle system-installed bcrypt in integration tests On Debian/Ubuntu, bcrypt may be installed by the system package manager with no RECORD file, making pip downgrade impossible. Move bcrypt installation into a self-contained block in test_schemes.yml with ignore_errors, a functional passlib+bcrypt check, and always-cleanup. Bcrypt tests are skipped when a compatible version cannot be used. * refactor(htpasswd): extract obtain_crypt_context(); import CryptContext from passlib directly Extract context selection logic into obtain_crypt_context(). Import CryptContext inside the deps.declare("passlib") block instead of from module_utils/_crypt.py — passlib is already a hard dependency and other symbols are imported from it there. Remove now-unnecessary htpasswd.py sanity import ignore entries. --------- (cherry picked from commit 49ca175f0152c9ee3c617ac7b1450cfef46af782) Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- .../12123-htpasswd-crypt-schemes.yml | 4 + plugins/modules/htpasswd.py | 22 +++- .../targets/htpasswd/tasks/main.yml | 3 + .../targets/htpasswd/tasks/test_schemes.yml | 116 ++++++++++++++++++ .../targets/htpasswd/vars/main.yml | 1 + 5 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 changelogs/fragments/12123-htpasswd-crypt-schemes.yml create mode 100644 tests/integration/targets/htpasswd/tasks/test_schemes.yml diff --git a/changelogs/fragments/12123-htpasswd-crypt-schemes.yml b/changelogs/fragments/12123-htpasswd-crypt-schemes.yml new file mode 100644 index 0000000000..cf90944377 --- /dev/null +++ b/changelogs/fragments/12123-htpasswd-crypt-schemes.yml @@ -0,0 +1,4 @@ +bugfixes: + - "htpasswd - fix ``hash_scheme`` aliases and Apache-compatible ``bcrypt`` hashes + (https://github.com/ansible-collections/community.general/issues/6135, + https://github.com/ansible-collections/community.general/pull/12123)." diff --git a/plugins/modules/htpasswd.py b/plugins/modules/htpasswd.py index 6d41e64083..4e43c05ab0 100644 --- a/plugins/modules/htpasswd.py +++ b/plugins/modules/htpasswd.py @@ -66,6 +66,8 @@ notes: - 'On Debian < 11, Ubuntu <= 20.04, or Fedora: install C(python-passlib).' - 'On Debian, Ubuntu: install C(python3-passlib).' - 'On RHEL or CentOS: Enable EPEL, then install C(python-passlib).' + - To use V(bcrypt) as O(hash_scheme), the C(bcrypt) Python library must also be installed. + Due to incompatibilities in C(passlib) 1.7.x, use C(bcrypt<4.2). requirements: [passlib>=1.6] author: "Ansible Core Team" extends_documentation_fragment: @@ -109,7 +111,7 @@ with deps.declare("passlib"): # Apparently the type infos don't know htpasswd_context, which *does* exist # (but isn't mentioned in the documentation for some reason) from passlib.apache import HtpasswdFile, htpasswd_context # type: ignore[attr-defined] - from passlib.context import CryptContext + from passlib.context import CryptContext # type: ignore[assignment] apache_hashes = ["apr_md5_crypt", "des_crypt", "ldap_sha1", "plaintext"] @@ -121,14 +123,24 @@ def create_missing_directories(dest): os.makedirs(destpath) +def obtain_crypt_context(hash_scheme): + if hash_scheme in apache_hashes or hash_scheme in htpasswd_context.schemes(): + # Use htpasswd_context for all officially-supported schemes, including bcrypt + # (htpasswd_context produces Apache-compatible $2y$ bcrypt, not $2b$) + return htpasswd_context + try: + return CryptContext(schemes=[hash_scheme] + apache_hashes) + except KeyError: + # hash_scheme is a HtpasswdFile alias (e.g. portable, portable_apache_24) + # that is not a valid passlib scheme name; let HtpasswdFile resolve it natively + return htpasswd_context + + def present(dest, username, password, hash_scheme, create, check_mode): """Ensures user is present Returns (msg, changed)""" - if hash_scheme in apache_hashes: - context = htpasswd_context - else: - context = CryptContext(schemes=[hash_scheme] + apache_hashes) + context = obtain_crypt_context(hash_scheme) if not os.path.exists(dest): if not create: raise ValueError(f"Destination {dest} does not exist") diff --git a/tests/integration/targets/htpasswd/tasks/main.yml b/tests/integration/targets/htpasswd/tasks/main.yml index 2e3e568e00..17fa34ffba 100644 --- a/tests/integration/targets/htpasswd/tasks/main.yml +++ b/tests/integration/targets/htpasswd/tasks/main.yml @@ -122,3 +122,6 @@ - del_bob_check is changed - del_bob is changed - del_bob_idempot is not changed + + - name: test non-standard hash schemes (issue 6135) + ansible.builtin.include_tasks: test_schemes.yml diff --git a/tests/integration/targets/htpasswd/tasks/test_schemes.yml b/tests/integration/targets/htpasswd/tasks/test_schemes.yml new file mode 100644 index 0000000000..624acce134 --- /dev/null +++ b/tests/integration/targets/htpasswd/tasks/test_schemes.yml @@ -0,0 +1,116 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Tests for non-standard hash_scheme values (issue #6135): +# - HtpasswdFile aliases (portable_apache_24, portable, host_apache_24) +# - bcrypt producing Apache-compatible $2y$ prefix instead of $2b$ + +- name: add user with portable_apache_24 alias + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_portable_24 + password: testpass + hash_scheme: portable_apache_24 + register: scheme_portable24 + +- name: add user with portable_apache_24 alias (idempotency) + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_portable_24 + password: testpass + hash_scheme: portable_apache_24 + register: scheme_portable24_idempot + +- name: assert portable_apache_24 alias + ansible.builtin.assert: + that: + - scheme_portable24 is changed + - scheme_portable24_idempot is not changed + +- name: add user with portable alias + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_portable + password: testpass + hash_scheme: portable + register: scheme_portable + +- name: add user with portable alias (idempotency) + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_portable + password: testpass + hash_scheme: portable + register: scheme_portable_idempot + +- name: assert portable alias + ansible.builtin.assert: + that: + - scheme_portable is changed + - scheme_portable_idempot is not changed + +- name: bcrypt scheme tests (requires bcrypt<4.2 to be compatible with passlib 1.7.x) + block: + - name: install compatible bcrypt + ansible.builtin.pip: + name: bcrypt<4.2 + register: bcrypt_pip_install + ignore_errors: true + + - name: check if passlib can use bcrypt + ansible.builtin.command: + cmd: >- + python3 -c " + from passlib.apache import HtpasswdFile; + import tempfile, os; + f = tempfile.mktemp(); + ht = HtpasswdFile(f, new=True, default_scheme='bcrypt'); + ht.set_password('t', 'p'); + ht.save(); + os.unlink(f)" + register: bcrypt_usable + ignore_errors: true + changed_when: false + + - name: run bcrypt tests + when: bcrypt_usable is not failed + block: + - name: add user with bcrypt scheme + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_bcrypt + password: testpass + hash_scheme: bcrypt + register: scheme_bcrypt + + - name: add user with bcrypt scheme (idempotency) + community.general.htpasswd: + path: "{{ htpasswd_schemes_path }}" + name: user_bcrypt + password: testpass + hash_scheme: bcrypt + register: scheme_bcrypt_idempot + + - name: read htpasswd file to verify bcrypt hash prefix + ansible.builtin.slurp: + src: "{{ htpasswd_schemes_path }}" + register: htpasswd_contents + + - name: assert bcrypt scheme + ansible.builtin.assert: + that: + - scheme_bcrypt is changed + - scheme_bcrypt_idempot is not changed + # Apache requires $2y$ prefix; passlib's CryptContext produces $2b$ — verify the fix + - >- + htpasswd_contents.content | b64decode | regex_search('^user_bcrypt:\$2y\$', multiline=True) is not none + + always: + - name: remove bcrypt if installed by pip + ansible.builtin.pip: + name: bcrypt + state: absent + when: bcrypt_pip_install is not failed + ignore_errors: true diff --git a/tests/integration/targets/htpasswd/vars/main.yml b/tests/integration/targets/htpasswd/vars/main.yml index ff81959c4f..2079b278a8 100644 --- a/tests/integration/targets/htpasswd/vars/main.yml +++ b/tests/integration/targets/htpasswd/vars/main.yml @@ -4,3 +4,4 @@ # SPDX-License-Identifier: GPL-3.0-or-later htpasswd_path: "{{ remote_tmp_dir }}/dot_htpasswd" +htpasswd_schemes_path: "{{ remote_tmp_dir }}/dot_htpasswd_schemes"