1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-06-11 10:35:34 +00:00

[PR #12123/49ca175f backport][stable-13] htpasswd: fix hash_scheme aliases and Apache-compatible bcrypt (#12156)

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 49ca175f01)

Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
patchback[bot] 2026-05-31 16:58:57 +02:00 committed by GitHub
parent 709e596700
commit 544b3ce678
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 141 additions and 5 deletions

View file

@ -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)."

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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"