Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Escape arguments for paramiko.SSHClient().exec_command

What is the best way to escape a string for safe usage as a command-line argument? I know that using subprocess.Popen takes care of this using list2cmdline(), but that doesn't seem to work correctly for paramiko. Example:

from subprocess import Popen
Popen(['touch', 'foo;uptime']).wait()

This creates a file named literally foo;uptime, which is what I want. Compare:

from paramiko import SSHClient()
from subprocess import list2cmdline
ssh = SSHClient()
#... load host keys and connect to a server
stdin, stdout, stderr = ssh.exec_command(list2cmdline(['touch', 'foo;uptime']))
print stdout.read()

This creates a file called foo and prints the uptime of the remote host. It has executed uptime as a second command instead of using it as part of the argument to the first command, touch. This is not what I want.

I tried escaping the semicolon with a backslash before and after sending it to list2cmdline, but then I ended up with a file called foo\;uptime.

Also, it works correctly if instead of uptime, you use a command with a space:

stdin, stdout, stderr = ssh.exec_command(list2cmdline(['touch', 'foo;echo test']))
print stdout.read()

This creates a file literally called foo;echo test because list2cmdline surrounded it with quotes.

Also, I tried pipes.quote() and it had the same effect as list2cmdline.

EDIT: To clarify, I need to make sure that only a single command gets executed on the remote host, regardless of the whatever input data I receive, which means escaping characters like ;, &, and the backtick.

like image 463
AdmiralNemo Avatar asked Jul 02 '10 04:07

AdmiralNemo


2 Answers

Assuming the remote user has a POSIX shell, this should work:

def shell_escape(arg):
    return "'%s'" % (arg.replace(r"'", r"'\''"), )

Why does this work?

POSIX shell single quotes are defined as:

Enclosing characters in single-quotes ( '' ) shall preserve the literal value of each character within the single-quotes. A single-quote cannot occur within single-quotes.

The idea here is that you enclose the string in single quotes. This, alone, is almost good enough --- every character except a single quote will be interpreted literally. For single quotes, you drop out of the single-quoted string (the first '), add a single quote (the \'), and then resume the single quoted string (the last ').

What does this work with?

This should work for any POSIX shell. I've tested it with dash and bash. Solaris 5.10's /bin/sh (which I believe is not POSIX-compatible, and I couldn't find a spec for) also seems to work.

For arbitrary remote hosts, I believe this is impossible. I think ssh will execute your command with whatever the remote user's shell (as configured in /etc/passwd or equivalent). If the remote user might be running, say, /usr/bin/python or git-shell or something, not only is any quoting scheme probably going to run into cross-shell inconsistencies, but you command execution is probably going to fail too.

csh / tcsh

Slightly more problematic is the possibility that the remote user might be running tcsh, since some people actually do run that in the wild and might expect paramiko's exec_command to work. (Users of /usr/bin/python as a shell probably have no such expectations...)

tcsh seems to mostly work. However, I can't figure out a way to quote a newline such that it will be happy. Including a newline in single-quoted string seems to make tcsh unhappy:

$ tcsh -c $'echo \'foo\nbar\''
Unmatched '.
Unmatched '.

Other than newlines, everything I've tried seems to work with tcsh (including single quotes, double quotes, backslashes, embedded tabs, asterisks, ...).

Testing shell escaping

If you have an escaping scheme, here are some things you might want to test with:

  • Escape sequences (\n, \t, ...)
  • Quotes (', ", \)
  • Globbing characters (*, ?, [], etc.)
  • Job control and pipelines (|, &, ||, &&, ...)
  • Newlines

Newlines are worth a special note. The re.escape solution doesn't handle this right --- it escapes any non-alphanumeric character, and POSIX shell considers an escaped newline (ie, in Python, the two-letter string "\\\n") to be zero characters, not a single newline character. I think re.escape handles all other cases correctly, though it scares me to use something designed for regular expressions to do escaping for shell. It might turn out to work, but I'd worry about a subtle case in re.escape or shell escaping rules (like newlines), or possible future changes in the API.

You should also be aware that escape sequences can get processed at various stages, which complicates testing things --- you only care about what the shell passes to a program, not what the program does. Using printf "%s\n" escaped-string-to-test is probably the best bet. echo works surprisingly poorly: In dash, the echo built-in processes backslash escapes like \n. Using /bin/echo is usually safe, but on a Solaris 5.10 machine I tested on, it also handles sequences like \n.

like image 71
Alex Dehnert Avatar answered Oct 06 '22 18:10

Alex Dehnert


You are not having success with list2cmdline() because it targets the Microsoft command line, which has different rules than the POSIX command line with which you are communicating using SSH.

Instead, use the native Python routine pipes.quote(), and be careful to apply it separately to each argument in the command. This will give you a working command line for SSH:

from pipes import quote
command = ['touch', 'foo;uptime']
print ' '.join(quote(s) for s in command)

The output carefully quotes the second argument to protect the ; character:

touch 'foo;uptime'
like image 33
Brandon Rhodes Avatar answered Oct 06 '22 18:10

Brandon Rhodes