diff --git a/changelogs/fragments/85359-askpass-incorrect-password-retries.yml b/changelogs/fragments/85359-askpass-incorrect-password-retries.yml new file mode 100644 index 00000000000..9890fa161f2 --- /dev/null +++ b/changelogs/fragments/85359-askpass-incorrect-password-retries.yml @@ -0,0 +1,2 @@ +bugfixes: + - ssh connection plugin - Allow only one password prompt attempt when utilizing ``SSH_ASKPASS`` (https://github.com/ansible/ansible/issues/85359) diff --git a/lib/ansible/cli/_ssh_askpass.py b/lib/ansible/cli/_ssh_askpass.py index c5d414cdbd6..47cb1299780 100644 --- a/lib/ansible/cli/_ssh_askpass.py +++ b/lib/ansible/cli/_ssh_askpass.py @@ -3,45 +3,52 @@ from __future__ import annotations import json +import multiprocessing.resource_tracker import os import re import sys import typing as t -from multiprocessing.shared_memory import SharedMemory -HOST_KEY_RE = re.compile( - r'(The authenticity of host |differs from the key for the IP address)', -) +from multiprocessing.shared_memory import SharedMemory def main() -> t.Never: - try: - if HOST_KEY_RE.search(sys.argv[1]): - sys.stdout.buffer.write(b'no') - sys.stdout.flush() - sys.exit(0) - except IndexError: - pass - - kwargs: dict[str, bool] = {} - if sys.version_info[:2] >= (3, 13): - # deprecated: description='unneeded due to track argument for SharedMemory' python_version='3.12' - kwargs['track'] = False - try: - shm = SharedMemory(name=os.environ['_ANSIBLE_SSH_ASKPASS_SHM'], **kwargs) - except FileNotFoundError: - # We must be running after the ansible fork is shutting down - sys.exit(1) + if len(sys.argv) > 1: + exit_code = 0 if handle_prompt(sys.argv[1]) else 1 + else: + exit_code = 1 + + sys.exit(exit_code) + + +def handle_prompt(prompt: str) -> bool: + if re.search(r'(The authenticity of host |differs from the key for the IP address)', prompt): + sys.stdout.write('no') + sys.stdout.flush() + return True + + # deprecated: description='Python 3.13 and later support track' python_version='3.12' + can_track = sys.version_info[:2] >= (3, 13) + kwargs = dict(track=False) if can_track else {} + + # This SharedMemory instance is intentionally not closed or unlinked. + # Closing will occur naturally in the SharedMemory finalizer. + # Unlinking is the responsibility of the process which created it. + shm = SharedMemory(name=os.environ['_ANSIBLE_SSH_ASKPASS_SHM'], **kwargs) + + if not can_track: + # When track=False is not available, we must unregister explicitly, since it otherwise only occurs during unlink. + # This avoids resource tracker noise on stderr during process exit. + multiprocessing.resource_tracker.unregister(shm._name, 'shared_memory') + cfg = json.loads(shm.buf.tobytes().rstrip(b'\x00')) - try: - if cfg['prompt'] not in sys.argv[1]: - sys.exit(1) - except IndexError: - sys.exit(1) + if cfg['prompt'] not in prompt: + return False - sys.stdout.buffer.write(cfg['password'].encode('utf-8')) + # Report the password provided by the SharedMemory instance. + # The contents are left untouched after consumption to allow subsequent attempts to succeed. + # This can occur when multiple password prompting methods are enabled, such as password and keyboard-interactive, which is the default on macOS. + sys.stdout.write(cfg['password']) sys.stdout.flush() - shm.buf[:] = b'\x00' * shm.size - shm.close() - sys.exit(0) + return True diff --git a/lib/ansible/plugins/connection/ssh.py b/lib/ansible/plugins/connection/ssh.py index 4ef400e8186..08ff188cf6c 100644 --- a/lib/ansible/plugins/connection/ssh.py +++ b/lib/ansible/plugins/connection/ssh.py @@ -640,11 +640,11 @@ def _clean_shm(func): self.shm.close() with contextlib.suppress(FileNotFoundError): self.shm.unlink() - if not _HAS_RESOURCE_TRACK: - # deprecated: description='unneeded due to track argument for SharedMemory' python_version='3.12' - # There is a resource tracking issue where the resource is deleted, but tracking still has a record - # This will effectively overwrite the record and remove it - SharedMemory(name=self.shm.name, create=True, size=1).unlink() + if not _HAS_RESOURCE_TRACK: + # deprecated: description='unneeded due to track argument for SharedMemory' python_version='3.12' + # There is a resource tracking issue where the resource is deleted, but tracking still has a record + # This will effectively overwrite the record and remove it + SharedMemory(name=self.shm.name, create=True, size=1).unlink() return ret return inner @@ -961,6 +961,13 @@ class Connection(ConnectionBase): b_args = (b"-o", b'ControlPath="%s"' % to_bytes(self.control_path % dict(directory=cpdir), errors='surrogate_or_strict')) self._add_args(b_command, b_args, u"found only ControlPersist; added ControlPath") + if password_mechanism == "ssh_askpass": + self._add_args( + b_command, + (b"-o", b"NumberOfPasswordPrompts=1"), + "Restrict number of password prompts in case incorrect password is provided.", + ) + # Finally, we add any caller-supplied extras. if other_args: b_command += [to_bytes(a) for a in other_args] diff --git a/test/integration/targets/connection_ssh/test_ssh_askpass.yml b/test/integration/targets/connection_ssh/test_ssh_askpass.yml index 506a200813c..e89438aaf41 100644 --- a/test/integration/targets/connection_ssh/test_ssh_askpass.yml +++ b/test/integration/targets/connection_ssh/test_ssh_askpass.yml @@ -23,7 +23,42 @@ state: restarted when: ansible_facts.system != 'Darwin' - - command: + - name: Test incorrect password + command: + argv: + - ansible + - localhost + - -m + - command + - -a + - id + - -vvv + - -e + - ansible_pipelining=yes + - -e + - ansible_connection=ssh + - -e + - ansible_ssh_password_mechanism=ssh_askpass + - -e + - ansible_user={{ test_user_name }} + - -e + - ansible_password=INCORRECT_PASSWORD + environment: + ANSIBLE_NOCOLOR: "1" + ANSIBLE_FORCE_COLOR: "0" + register: askpass_out + ignore_errors: true + + - assert: + that: + - askpass_out is failed + - askpass_out.stdout is contains('UNREACHABLE') + - askpass_out.stdout is contains('Permission denied') + - askpass_out.stdout is not contains('Permission denied, please try again.') # password tried only once + - askpass_out.stdout is not contains('Traceback (most recent call last)') + + - name: Test correct password + command: argv: - ansible - localhost