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:

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
@@ -294,6 +294,19 @@
       - "--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
 
 
@@ -446,7 +459,7 @@ def main():
         ssh_opts = '%s -o StrictHostKeyChecking=no' % ssh_opts
 
     if ssh_args:
-        ssh_opts = '%s %s' % (ssh_opts, ssh_args)
+        ssh_opts = '%s %s' % (ssh_opts, shlex_quote(ssh_args))
 
     if source.startswith('"rsync://') and dest.startswith('"rsync://'):
         module.fail_json(msg='either src or dest must be a localhost', rc=1)
@@ -456,9 +469,9 @@ def main():
         # 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)
+            cmd += " --rsh '%s %s %s -o Port=%s'" % (ssh, private_key, ssh_opts, dest_port)
         else:
-            cmd += " --rsh 'ssh %s %s'" % (private_key, ssh_opts)
+            cmd += " --rsh '%s %s %s'" % (ssh, private_key, ssh_opts)
 
     if rsync_path:
         cmd = cmd + " --rsync-path=%s" % (rsync_path)
@@ -498,8 +511,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: