From: =?utf-8?q?Bastien_Roucari=C3=A8s?= <rouca@debian.org>
Date: Sun, 24 Dec 2023 11:59:07 +0000
Subject: CVE-2022-3697: ec2_instance - validate options on tower_callback

A flaw was found in Ansible in the amazon.aws collection when using the
tower_callback parameter from the amazon.aws.ec2_instance module.
This flaw allows an attacker to take advantage of this issue as
the module is handling the parameter insecurely, leading to the password leaking in the logs

https://salsa.debian.org/python-team/packages/ansible/-/blob/debian/2.7.7+dfsg-1+deb10u2/debian/patches/0029-CVE-2022-3697-ec2_instance-validate-options-on-tower.patch?ref_type=tags
origin: https://patch-diff.githubusercontent.com/raw/ansible-collections/amazon.aws/pull/1207.patch
bug: https://github.com/ansible-collections/amazon.aws/pull/1199
bug-redhat: https://bugzilla.redhat.com/show_bug.cgi?id=2137664

---
 .../20221021-ec2_instance-tower_callback.yml  |   5 +
 lib/ansible/module_utils/aws/tower.py         |  83 +++++++++++
 .../modules/cloud/amazon/ec2_instance.py      | 137 ++++++++----------
 test/units/module_utils/aws/test_tower.py     |  40 +++++
 4 files changed, 187 insertions(+), 78 deletions(-)
 create mode 100644 changelogs/fragments/20221021-ec2_instance-tower_callback.yml
 create mode 100644 lib/ansible/module_utils/aws/tower.py
 create mode 100644 test/units/module_utils/aws/test_tower.py

diff --git a/changelogs/fragments/20221021-ec2_instance-tower_callback.yml b/changelogs/fragments/20221021-ec2_instance-tower_callback.yml
new file mode 100644
index 00000000..ae961144
--- /dev/null
+++ b/changelogs/fragments/20221021-ec2_instance-tower_callback.yml
@@ -0,0 +1,5 @@
+minor_changes:
+- ec2_instance - refacter ``tower_callback`` code to handle parameter validation as part of the argument specification (https://github.com/ansible-collections/amazon.aws/pull/1199).
+- ec2_instance - the ``tower_callback`` parameter has been renamed to ``aap_callback``, ``tower_callback`` remains as an alias.  This change should have no observable effect for users outside the module documentation (https://github.com/ansible-collections/amazon.aws/pull/1199).
+security_fixes:
+- ec2_instance - fixes leak of password into logs when using ``tower_callback.windows=True`` and ``tower_callback.set_password`` (https://github.com/ansible-collections/amazon.aws/pull/1199).
diff --git a/lib/ansible/module_utils/aws/tower.py b/lib/ansible/module_utils/aws/tower.py
new file mode 100644
index 00000000..dd7d9738
--- /dev/null
+++ b/lib/ansible/module_utils/aws/tower.py
@@ -0,0 +1,83 @@
+# Copyright: Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+import string
+import textwrap
+
+from ansible.module_utils._text import to_native
+from ansible.module_utils.six.moves.urllib import parse as urlparse
+
+
+def _windows_callback_script(passwd=None):
+    script_url = 'https://raw.githubusercontent.com/ansible/ansible/devel/examples/scripts/ConfigureRemotingForAnsible.ps1'
+    if passwd is not None:
+        passwd = passwd.replace("'", "''")
+        script_tpl = """\
+        <powershell>
+        $admin = [adsi]('WinNT://./administrator, user')
+        $admin.PSBase.Invoke('SetPassword', '${PASS}')
+        Invoke-Expression ((New-Object System.Net.Webclient).DownloadString('${SCRIPT}'))
+        </powershell>
+        """
+    else:
+        script_tpl = """\
+        <powershell>
+        $admin = [adsi]('WinNT://./administrator, user')
+        Invoke-Expression ((New-Object System.Net.Webclient).DownloadString('${SCRIPT}'))
+        </powershell>
+        """
+
+    tpl = string.Template(textwrap.dedent(script_tpl))
+    return tpl.safe_substitute(PASS=passwd, SCRIPT=script_url)
+
+
+def _linux_callback_script(tower_address, template_id, host_config_key):
+    template_id = urlparse.quote(template_id)
+    tower_address = urlparse.quote(tower_address)
+    host_config_key = host_config_key.replace("'", "'\"'\"'")
+
+    script_tpl = """\
+    #!/bin/bash
+    set -x
+
+    retry_attempts=10
+    attempt=0
+    while [[ $attempt -lt $retry_attempts ]]
+    do
+      status_code=$(curl --max-time 10 -v -k -s -i \
+        --data 'host_config_key=${host_config_key}' \
+        'https://${tower_address}/api/v2/job_templates/${template_id}/callback/' \
+        | head -n 1 \
+        | awk '{print $2}')
+      if [[ $status_code == 404 ]]
+        then
+        status_code=$(curl --max-time 10 -v -k -s -i \
+          --data 'host_config_key=${host_config_key}' \
+          'https://${tower_address}/api/v1/job_templates/${template_id}/callback/' \
+          | head -n 1 \
+          | awk '{print $2}')
+        # fall back to using V1 API for Tower 3.1 and below, since v2 API will always 404
+      fi
+      if [[ $status_code == 201 ]]
+        then
+        exit 0
+      fi
+      attempt=$(( attempt + 1 ))
+      echo "$${status_code} received... retrying in 1 minute. (Attempt $${attempt})"
+      sleep 60
+    done
+    exit 1
+    """
+    tpl = string.Template(textwrap.dedent(script_tpl))
+    return tpl.safe_substitute(tower_address=tower_address,
+                               template_id=template_id,
+                               host_config_key=host_config_key)
+
+
+def tower_callback_script(tower_address, job_template_id, host_config_key, windows, passwd):
+    if windows:
+        return to_native(_windows_callback_script(passwd=passwd))
+    return _linux_callback_script(tower_address, job_template_id, host_config_key)
diff --git a/lib/ansible/modules/cloud/amazon/ec2_instance.py b/lib/ansible/modules/cloud/amazon/ec2_instance.py
index fdde1eef..c74d8820 100644
--- a/lib/ansible/modules/cloud/amazon/ec2_instance.py
+++ b/lib/ansible/modules/cloud/amazon/ec2_instance.py
@@ -46,19 +46,40 @@ options:
   user_data:
     description:
       - Opaque blob of data which is made available to the ec2 instance
-  tower_callback:
+  aap_callback:
     description:
-      - Preconfigured user-data to enable an instance to perform a Tower callback (Linux only).
+      - Preconfigured user-data to enable an instance to perform an Ansible Automation Platform
+        callback (Linux only).
+      - For Windows instances, to enable remote access via Ansible set I(windows) to C(true), and
+        optionally set an admin password.
+      - If using I(windows) and I(set_password), callback ton Ansible Automation Platform will not
+        be performed but the instance will be ready to receive winrm connections from Ansible.
       - Mutually exclusive with I(user_data).
-      - For Windows instances, to enable remote access via Ansible set I(tower_callback.windows) to true, and optionally set an admin password.
-      - If using 'windows' and 'set_password', callback to Tower will not be performed but the instance will be ready to receive winrm connections from Ansible.
+    type: dict
+    aliases: ['tower_callback']
     suboptions:
+      windows:
+        description:
+          - Set I(windows=True) to use powershell instead of bash for the callback script.
+        type: bool
+        default: False
+      set_password:
+        description:
+          - Optional admin password to use if I(windows=True).
+        type: str
       tower_address:
         description:
-        - IP address or DNS name of Tower server. Must be accessible via this address from the VPC that this instance will be launched in.
+          - IP address or DNS name of Tower server. Must be accessible via this address from the
+            VPC that this instance will be launched in.
+          - Required if I(windows=False).
+        type: str
       job_template_id:
         description:
-        - Either the integer ID of the Tower Job Template, or the name (name supported only for Tower 3.2+).
+          - Either the integer ID of the Tower Job Template, or the name.
+            Using a name for the job template is not supported by Ansible Tower prior to version
+            3.2.
+          - Required if I(windows=False).
+        type: str
       host_config_key:
         description:
         - Host configuration secret key generated by the Tower job template.
@@ -729,71 +750,10 @@ from ansible.module_utils.ec2 import (boto3_conn,
                                       camel_dict_to_snake_dict)
 
 from ansible.module_utils.aws.core import AnsibleAWSModule
+from ansible.module_utils.aws.tower import tower_callback_script
 
 module = None
 
-
-def tower_callback_script(tower_conf, windows=False, passwd=None):
-    script_url = 'https://raw.githubusercontent.com/ansible/ansible/devel/examples/scripts/ConfigureRemotingForAnsible.ps1'
-    if windows and passwd is not None:
-        script_tpl = """<powershell>
-        $admin = [adsi]("WinNT://./administrator, user")
-        $admin.PSBase.Invoke("SetPassword", "{PASS}")
-        Invoke-Expression ((New-Object System.Net.Webclient).DownloadString('{SCRIPT}'))
-        </powershell>
-        """
-        return to_native(textwrap.dedent(script_tpl).format(PASS=passwd, SCRIPT=script_url))
-    elif windows and passwd is None:
-        script_tpl = """<powershell>
-        $admin = [adsi]("WinNT://./administrator, user")
-        Invoke-Expression ((New-Object System.Net.Webclient).DownloadString('{SCRIPT}'))
-        </powershell>
-        """
-        return to_native(textwrap.dedent(script_tpl).format(PASS=passwd, SCRIPT=script_url))
-    elif not windows:
-        for p in ['tower_address', 'job_template_id', 'host_config_key']:
-            if p not in tower_conf:
-                module.fail_json(msg="Incomplete tower_callback configuration. tower_callback.{0} not set.".format(p))
-
-        if isinstance(tower_conf['job_template_id'], string_types):
-            tower_conf['job_template_id'] = urlparse.quote(tower_conf['job_template_id'])
-        tpl = string.Template(textwrap.dedent("""#!/bin/bash
-        set -x
-
-        retry_attempts=10
-        attempt=0
-        while [[ $attempt -lt $retry_attempts ]]
-        do
-          status_code=`curl --max-time 10 -v -k -s -i \
-            --data "host_config_key=${host_config_key}" \
-            'https://${tower_address}/api/v2/job_templates/${template_id}/callback/' \
-            | head -n 1 \
-            | awk '{print $2}'`
-          if [[ $status_code == 404 ]]
-            then
-            status_code=`curl --max-time 10 -v -k -s -i \
-              --data "host_config_key=${host_config_key}" \
-              'https://${tower_address}/api/v1/job_templates/${template_id}/callback/' \
-              | head -n 1 \
-              | awk '{print $2}'`
-            # fall back to using V1 API for Tower 3.1 and below, since v2 API will always 404
-          fi
-          if [[ $status_code == 201 ]]
-            then
-            exit 0
-          fi
-          attempt=$(( attempt + 1 ))
-          echo "$${status_code} received... retrying in 1 minute. (Attempt $${attempt})"
-          sleep 60
-        done
-        exit 1
-        """))
-        return tpl.safe_substitute(tower_address=tower_conf['tower_address'],
-                                   template_id=tower_conf['job_template_id'],
-                                   host_config_key=tower_conf['host_config_key'])
-    raise NotImplementedError("Only windows with remote-prep or non-windows with tower job callback supported so far.")
-
-
 @AWSRetry.jittered_backoff()
 def manage_tags(match, new_tags, purge_tags, ec2):
     changed = False
@@ -1084,6 +1044,21 @@ def discover_security_groups(group, groups, parent_vpc_id=None, subnet_id=None,
     return list(dict((g['GroupId'], g) for g in found_groups).values())
 
 
+def build_userdata(params):
+    if params.get('user_data') is not None:
+        return {'UserData': to_native(params.get('user_data'))}
+    if params.get('aap_callback'):
+        userdata = tower_callback_script(
+            tower_address=params.get('aap_callback').get('tower_address'),
+            job_template_id=params.get('aap_callback').get('job_template_id'),
+            host_config_key=params.get('aap_callback').get('host_config_key'),
+            windows=params.get('aap_callback').get('windows'),
+            passwd=params.get('aap_callback').get('set_passwd'),
+        )
+        return {'UserData': userdata}
+    return {}
+
+
 def build_top_level_options(params):
     spec = {}
     if params.get('image_id'):
@@ -1100,14 +1075,8 @@ def build_top_level_options(params):
 
     if params.get('key_name') is not None:
         spec['KeyName'] = params.get('key_name')
-    if params.get('user_data') is not None:
-        spec['UserData'] = to_native(params.get('user_data'))
-    elif params.get('tower_callback') is not None:
-        spec['UserData'] = tower_callback_script(
-            tower_conf=params.get('tower_callback'),
-            windows=params.get('tower_callback').get('windows', False),
-            passwd=params.get('tower_callback').get('set_password'),
-        )
+
+    spec.update(build_userdata(params))
 
     if params.get('launch_template') is not None:
         spec['LaunchTemplate'] = {}
@@ -1586,7 +1555,19 @@ def main():
         image_id=dict(type='str'),
         instance_type=dict(default='t2.micro', type='str'),
         user_data=dict(type='str'),
-        tower_callback=dict(type='dict'),
+        aap_callback=dict(
+            type='dict', aliases=['tower_callback'],
+            required_if=[
+                ('windows', False, ('tower_address', 'job_template_id', 'host_config_key',), False),
+            ],
+            options=dict(
+                windows=dict(type='bool', default=False),
+                set_password=dict(type='str', no_log=True),
+                tower_address=dict(type='str'),
+                job_template_id=dict(type='str'),
+                host_config_key=dict(type='str', no_log=True),
+            ),
+        ),
         ebs_optimized=dict(type='bool'),
         vpc_subnet_id=dict(type='str', aliases=['subnet_id']),
         availability_zone=dict(type='str'),
@@ -1620,7 +1601,7 @@ def main():
         mutually_exclusive=[
             ['security_groups', 'security_group'],
             ['availability_zone', 'vpc_subnet_id'],
-            ['tower_callback', 'user_data'],
+            ['aap_callback', 'user_data'],
             ['image_id', 'image'],
         ],
         supports_check_mode=True
diff --git a/test/units/module_utils/aws/test_tower.py b/test/units/module_utils/aws/test_tower.py
new file mode 100644
index 00000000..527423b1
--- /dev/null
+++ b/test/units/module_utils/aws/test_tower.py
@@ -0,0 +1,40 @@
+# (c) 2022 Red Hat Inc.
+#
+# This file is part of Ansible
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+# import pytest
+
+import ansible.module_utils.aws.tower as utils_tower
+
+WINDOWS_DOWNLOAD = "Invoke-Expression ((New-Object System.Net.Webclient).DownloadString(" \
+    "'https://raw.githubusercontent.com/ansible/ansible/devel/examples/scripts/ConfigureRemotingForAnsible.ps1'))"
+EXAMPLE_PASSWORD = 'MY_EXAMPLE_PASSWORD'
+WINDOWS_INVOKE = "$admin.PSBase.Invoke('SetPassword', 'MY_EXAMPLE_PASSWORD'"
+
+EXAMPLE_TOWER = "tower.example.com"
+EXAMPLE_TEMPLATE = 'My Template'
+EXAMPLE_KEY = '123EXAMPLE123'
+LINUX_TRIGGER_V1 = 'https://tower.example.com/api/v1/job_templates/My%20Template/callback/'
+LINUX_TRIGGER_V2 = 'https://tower.example.com/api/v2/job_templates/My%20Template/callback/'
+
+
+def test_windows_callback_no_password():
+    user_data = utils_tower._windows_callback_script()
+    assert WINDOWS_DOWNLOAD in user_data
+    assert 'SetPassword' not in user_data
+
+
+def test_windows_callback_password():
+    user_data = utils_tower._windows_callback_script(EXAMPLE_PASSWORD)
+    assert WINDOWS_DOWNLOAD in user_data
+    assert WINDOWS_INVOKE in user_data
+
+
+def test_linux_callback_with_name():
+    user_data = utils_tower._linux_callback_script(EXAMPLE_TOWER, EXAMPLE_TEMPLATE, EXAMPLE_KEY)
+    assert LINUX_TRIGGER_V1 in user_data
+    assert LINUX_TRIGGER_V2 in user_data
-- 
2.33.0

