How to avoid having ssh mangle spaces in command-line args


Problem statement

In a recent Hackaday article titled SSH Can Handle Spaces In Command-line Arguments Strangely, Donald Papp discusses Martin Kjellstrand’s article titled SSH might surprise you — in all the wrong ways.

The gist of the issue is simple:

$ cat a.rb 
#!/usr/bin/env ruby
p ARGV

$ ./a.rb foo 'bar baz'
["foo", "bar baz"]

$ ssh localhost ./a.rb foo 'bar baz'
["foo", "bar", "baz"]
Connection to localhost closed.

SSH has its own ideas on how to split up command-line args.

I encountered this problem sometime in 20101 because I started running firefox in a remote container…

And I promptly solved that issue… in the laziest way possible.

Let’s dig in.

Discussion

In both the Hackaday and Martin’s article they ask the mildly interesting question of why this is. And I’m not going to answer that here, because I don’t really care.

The way I approached this quirk is relatively straightforward:

Base64-encode all arguments before launching the ssh, and then base64-decode it on the receiving end, and execve as expected. Problem solved.

Granted, this fixes the obvious problem at the cost of some corner cases. The most prominent two are:

  1. each argument grows by 1/3 in size2
  2. each remote server has to have the rc_unwrap script, in $PATH

Is that the right tradeoff for you? IDK, it is for my peace of mind.

Solution

On local side, perhaps as remotely_call:

#!/usr/bin/env ruby

# Base64-encodes command and argv, and using `rc_unwrap` executes that
# on a remote server using ssh.

if ARGV.size < 2
    STDERR.puts "Usage: #{File.basename($0)} <server> <command> <arg>+"
    exit 1
end

server = ARGV.shift

args = ARGV.map { |x| [x].pack('m0') }

cmd = ["ssh", server, "--", "rc_unwrap"] + args
cmd.unshift("echo") if ENV["REMOTELY_CALL_SHOW_DONT_RUN"]

STDERR.puts "Calling: #{cmd.inspect}" if $DEBUG || $VERBOSE
exec(*cmd)

On remote side, as rc_unwrap somewhere in $PATH:

#!/usr/bin/env ruby

# Base64-decodes command and argv, and executes them.

if ARGV.size < 1
    STDERR.puts "Usage: #{File.basename($0)} <command> <arg>+"
    exit 1
end

cmd = ARGV.map { |x| x.unpack('m').first }
cmd.unshift("echo") if ENV["RC_UNWRAP_SHOW_DONT_RUN"]

STDERR.puts "Running: #{cmd.inspect}" if $DEBUG || $VERBOSE
exec(*cmd)

This setup, then, avoids the original issue3:

$ cat a.rb 
#!/usr/bin/env ruby
p ARGV

$ ./a.rb foo 'bar baz'
["foo", "bar baz"]

$ remotely_call localhost ./a.rb foo 'bar baz'
["foo", "bar baz"]

and to aid debugging, you can inspect the beauty horror being passed to ssh4 in two ways:

$ ruby -v bin/remotely_call localhost ./a.rb foo 'bar baz'
[...]
Calling: ["ssh", "localhost", "--", "rc_unwrap", "Li9hLnJi", "Zm9v", "YmFyIGJheg=="]
["foo", "bar baz"]

$ REMOTELY_CALL_SHOW_DONT_RUN=1 remotely_call localhost ./a.rb foo 'bar baz'
ssh localhost -- rc_unwrap Li9hLnJi Zm9v YmFyIGJheg==

Closing words

This is hardly something revolutionary; I’m fully aware of that fact. But I was equally “aware” of “surely everyone knows ssh is being an idiot when it comes to spaces” back in 2010. ;)

So maybe it inspires someone to dig deeper. Or come up with a better solution than this. And if you do, let me know…

  1. Oddly specific, eh? Well, if mtime on one of the wrapper scripts I wrote to work this around is to be believed.

  2. And you won’t care until you want to pass on something super long… and then it blows up again (where local invocation doesn’t).

  3. And possibly future surprises, as long as base64-encoded strings aren’t mangled by ssh.

  4. Not that it isn’t obvious in simple cases like this. But originally I had a dumb bug in the base64 encoding – pack('m') instead of pack('m0'), and debugging that with this debug tap is easier.