Repository: ansible/ansible

Issue:
synchronize module does not escape SSH args when using `use_ssh_args`
##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME

`synchronize` module

##### ANSIBLE VERSION
```
ansible 2.2.1.0
  config file = /home/admin/.ansible.cfg
  configured module search path = Default w/o overrides
```

##### CONFIGURATION

```
[defaults]
inventory=~/bin/inventory
callback_whitelist = profile_tasks
retry_files_save_path = /tmp

[ssh_connection]
control_path = %(directory)s/%%C
```

##### OS / ENVIRONMENT

`Linux ip-10-254-0-21 3.16.0-4-amd64 #1 SMP Debian 3.16.39-1+deb8u1 (2017-02-22) x86_64 GNU/Linux`

Debian 8

##### SUMMARY

We set host specific `ansible_ssh_common_args` in our dynamic inventory to tunnel through a the relevant bastion when applying changes. A given host in the inventory might look like:

```
      "ip-XXXX.us-west-2.compute.internal": {
        "ansible_ssh_common_args": "-o User=XXXX -o 'ProxyCommand ssh -A  -q -l XXXX X.X.X.X nc %h %p'"
      }
```

Note that the `-o` for the `ProxyCommand` uses `'`s.  When setting `use_ssh_args: yes` on the `synchronize` module, it attempts to pass the arguments through as the `--rsh` flag wrapped in single quotes, but does not escape single quotes within the flag value. Thus you end up with something that looks like:

```
.... --rsh 'ssh  -S none  -o ControlMaster=auto -o ControlPersist=60s -o User=XXXX -o 'ProxyCommand ssh -A  -q -l XXXX X.X.X.X nc %h %p'' --out-format='<<CHANGED>>%i %n%L' "ip-XXXX.us-west-2.compute.internal:/XXXX" "/home/XXXX" ....
```

yielding an error of:

```
Unexpected remote arg: ip-XXXX.us-west-2.compute.internal:/XXXX
```

##### STEPS TO REPRODUCE

```yaml
---
- hosts: all
  gather_facts: no
  tasks:
    - name: download output
      synchronize:
        mode: pull
        src: "XXXX"
        dest: "XXXX"
        use_ssh_args: yes
```

where you set `ansible_ssh_common_args` to something that has a single quote in it.

##### EXPECTED RESULTS

The files are fetched

##### ACTUAL RESULTS

```
PLAY [all] *********************************************************************

TASK [download output] *********************************************************
Tuesday 28 February 2017  23:11:51 +0000 (0:00:00.016)       0:00:00.016 ******
fatal: [ip-XXXX.us-west-2.compute.internal]: FAILED! => {"changed": false, "cmd": "/usr/bin/rsync --delay-updates -F --compress --archive --rsh 'ssh  -S none -C -o ControlMaster=auto -o ControlPersist=60s -o User=XXXX -o 'ProxyCommand ssh -A -q -l XXXX X.X.X.X nc %h %p'' --out-format='<<CHANGED>>%i %n%L' \"ip-XXXX.us-west-2.compute.internal:/XXXX\" \"/XXXX\"", "failed": true, "msg": "Unexpected remote arg: ip-XXXX.us-west-2.compute.internal:/XXXX\nrsync error: syntax or usage error (code 1) at main.c(1348) [sender=3.1.1]\n", "rc": 1}
        to retry, use: --limit @/tmp/XXXX.retry

PLAY RECAP *********************************************************************
ip-XXXX.us-west-2.compute.internal : ok=0    changed=0    unreachable=0    failed=1
```



Discussion:
@jszwedko could you please test #22162 for me?  I don't have a setup that needs ssh_args right now so I didn't test it but I think that will quote the args in the proper place.

Resolving patch:
diff --git a/lib/ansible/modules/files/synchronize.py b/lib/ansible/modules/files/synchronize.py
--- a/lib/ansible/modules/files/synchronize.py
+++ b/lib/ansible/modules/files/synchronize.py
@@ -296,6 +296,20 @@
       - "--no-motd"
       - "--exclude=.git"
 '''
+
+
+import os
+
+# Python3 compat. six.moves.shlex_quote will be available once we're free to
+# upgrade beyond six-1.4 module-side.
+try:
+    from shlex import quote as shlex_quote
+except ImportError:
+    from pipes import quote as shlex_quote
+
+from ansible.module_utils.basic import AnsibleModule
+
+
 client_addr = None
 
 
@@ -337,7 +351,7 @@ def main():
             dirs  = dict(default='no', type='bool'),
             recursive = dict(type='bool'),
             links = dict(type='bool'),
-            copy_links = dict(type='bool'),
+            copy_links = dict(default='no', type='bool'),
             perms = dict(type='bool'),
             times = dict(type='bool'),
             owner = dict(type='bool'),
@@ -355,13 +369,13 @@ def main():
 
     if module.params['_substitute_controller']:
         try:
-            source = '"' + substitute_controller(module.params['src']) + '"'
-            dest = '"' + substitute_controller(module.params['dest']) + '"'
+            source = substitute_controller(module.params['src'])
+            dest = substitute_controller(module.params['dest'])
         except ValueError:
             module.fail_json(msg='Could not determine controller hostname for rsync to send to')
     else:
-        source = '"' + module.params['src'] + '"'
-        dest = '"' + module.params['dest'] + '"'
+        source = module.params['src']
+        dest = module.params['dest']
     dest_port = module.params['dest_port']
     delete = module.params['delete']
     private_key = module.params['private_key']
@@ -389,90 +403,81 @@ def main():
     if '/' not in rsync:
         rsync = module.get_bin_path(rsync, required=True)
 
-    ssh = module.get_bin_path('ssh', required=True)
-
-    cmd = '%s --delay-updates -F' % rsync
+    cmd = [rsync, '--delay-updates', '-F']
     if compress:
-        cmd = cmd + ' --compress'
+        cmd.append('--compress')
     if rsync_timeout:
-        cmd = cmd + ' --timeout=%s' % rsync_timeout
+        cmd.append('--timeout=%s' % rsync_timeout)
     if module.check_mode:
-        cmd = cmd + ' --dry-run'
+        cmd.append('--dry-run')
     if delete:
-        cmd = cmd + ' --delete-after'
+        cmd.append('--delete-after')
     if existing_only:
-        cmd = cmd + ' --existing'
+        cmd.append('--existing')
     if checksum:
-        cmd = cmd + ' --checksum'
+        cmd.append('--checksum')
+    if copy_links:
+        cmd.append('--copy-links')
     if archive:
-        cmd = cmd + ' --archive'
+        cmd.append('--archive')
         if recursive is False:
-            cmd = cmd + ' --no-recursive'
+            cmd.append('--no-recursive')
         if links is False:
-            cmd = cmd + ' --no-links'
-        if copy_links is True:
-            cmd = cmd + ' --copy-links'
+            cmd.append('--no-links')
         if perms is False:
-            cmd = cmd + ' --no-perms'
+            cmd.append('--no-perms')
         if times is False:
-            cmd = cmd + ' --no-times'
+            cmd.append('--no-times')
         if owner is False:
-            cmd = cmd + ' --no-owner'
+            cmd.append('--no-owner')
         if group is False:
-            cmd = cmd + ' --no-group'
+            cmd.append('--no-group')
     else:
         if recursive is True:
-            cmd = cmd + ' --recursive'
+            cmd.append('--recursive')
         if links is True:
-            cmd = cmd + ' --links'
-        if copy_links is True:
-            cmd = cmd + ' --copy-links'
+            cmd.append('--links')
         if perms is True:
-            cmd = cmd + ' --perms'
+            cmd.append('--perms')
         if times is True:
-            cmd = cmd + ' --times'
+            cmd.append('--times')
         if owner is True:
-            cmd = cmd + ' --owner'
+            cmd.append('--owner')
         if group is True:
-            cmd = cmd + ' --group'
+            cmd.append('--group')
     if dirs:
-        cmd = cmd + ' --dirs'
-    if private_key is None:
-        private_key = ''
-    else:
-        private_key = '-i "%s"' % private_key
-
-    ssh_opts = '-S none'
+        cmd.append('--dirs')
 
-    if not verify_host:
-        ssh_opts = '%s -o StrictHostKeyChecking=no' % ssh_opts
-
-    if ssh_args:
-        ssh_opts = '%s %s' % (ssh_opts, ssh_args)
-
-    if source.startswith('"rsync://') and dest.startswith('"rsync://'):
+    if source.startswith('rsync://') and dest.startswith('rsync://'):
         module.fail_json(msg='either src or dest must be a localhost', rc=1)
 
-    if not source.startswith('"rsync://') and not dest.startswith('"rsync://'):
+    if not source.startswith('rsync://') and not dest.startswith('rsync://'):
+        ssh_cmd = [module.get_bin_path('ssh', required=True), '-S', 'none']
+        if private_key is not None:
+            ssh_cmd.extend(['-i', private_key])
         # If the user specified a port value
         # Note:  The action plugin takes care of setting this to a port from
         # inventory if the user didn't specify an explicit dest_port
         if dest_port is not None:
-            cmd += " --rsh 'ssh %s %s -o Port=%s'" % (private_key, ssh_opts, dest_port)
-        else:
-            cmd += " --rsh 'ssh %s %s'" % (private_key, ssh_opts)
+            ssh_cmd.extend(['-o', 'Port=%s' % dest_port])
+        if not verify_host:
+            ssh_cmd.extend(['-o', 'StrictHostKeyChecking=no'])
+        if ssh_args:
+            ssh_cmd.append(ssh_args)
+        ssh_cmd_str = ' '.join(shlex_quote(arg) for arg in ssh_cmd)
+        cmd.append('--rsh=%s' % ssh_cmd_str)
 
     if rsync_path:
-        cmd = cmd + " --rsync-path=%s" % (rsync_path)
+        cmd.append('--rsync-path=%s' % rsync_path)
 
     if rsync_opts:
-        cmd = cmd + " " +  " ".join(rsync_opts)
+        cmd.extend(rsync_opts)
 
     if partial:
-        cmd = cmd + " --partial"
+        cmd.append('--partial')
 
     changed_marker = '<<CHANGED>>'
-    cmd = cmd + " --out-format='" + changed_marker + "%i %n%L'"
+    cmd.append('--out-format=' + changed_marker + '%i %n%L')
 
     # expand the paths
     if '@' not in source:
@@ -480,15 +485,16 @@ def main():
     if '@' not in dest:
         dest = os.path.expanduser(dest)
 
-    cmd = ' '.join([cmd, source, dest])
-    cmdstr = cmd
+    cmd.append(source)
+    cmd.append(dest)
+    cmdstr = ' '.join(cmd)
     (rc, out, err) = module.run_command(cmd)
     if rc:
         return module.fail_json(msg=err, rc=rc, cmd=cmdstr)
     else:
         changed = changed_marker in out
-        out_clean=out.replace(changed_marker,'')
-        out_lines=out_clean.split('\n')
+        out_clean = out.replace(changed_marker, '')
+        out_lines = out_clean.split('\n')
         while '' in out_lines:
             out_lines.remove('')
         if module._diff:
@@ -500,8 +506,6 @@ def main():
             return module.exit_json(changed=changed, msg=out_clean,
                                     rc=rc, cmd=cmdstr, stdout_lines=out_lines)
 
-# import module snippets
-from ansible.module_utils.basic import *
 
 if __name__ == '__main__':
     main()


Test patch: