Run shell commands safely, even with user-supplied values
The basic, normal stuff:
line = Terrapin::CommandLine.new("echo", "hello 'world'")
line.command # => "echo hello 'world'"
line.run # => "hello world\n"
Interpolated arguments:
line = Terrapin::CommandLine.new("convert", ":in -scale :resolution :out")
line.command(in: "omg.jpg",
resolution: "32x32",
out: "omg_thumb.jpg")
# => "convert 'omg.jpg' -scale '32x32' 'omg_thumb.jpg'"
It prevents attempts at being bad:
line = Terrapin::CommandLine.new("cat", ":file")
line.command(file: "haha`rm -rf /`.txt") # => "cat 'haha`rm -rf /`.txt'"
line = Terrapin::CommandLine.new("cat", ":file")
line.command(file: "ohyeah?'`rm -rf /`.ha!") # => "cat 'ohyeah?'\\''`rm -rf /`.ha!'"
NOTE: It only does that for arguments interpolated via run
, NOT arguments
passed into new
(see 'Security' below):
line = Terrapin::CommandLine.new("echo", "haha`whoami`")
line.command # => "echo haha`whoami`"
line.run # => "hahawebserver\n"
This is the right way:
line = Terrapin::CommandLine.new("echo", "haha:whoami")
line.command(whoami: "`whoami`") # => "echo haha'`whoami`'"
line.run(whoami: "`whoami`") # => "haha`whoami`\n"
You can ignore the result:
line = Terrapin::CommandLine.new("noisy", "--extra-verbose", swallow_stderr: true)
line.command # => "noisy --extra-verbose 2>/dev/null"
# ... and on Windows...
line.command # => "noisy --extra-verbose 2>NUL"
If your command errors, you get an exception:
line = Terrapin::CommandLine.new("git", "commit")
begin
line.run
rescue Terrapin::ExitStatusError => e
e.message # => "Command 'git commit' returned 1. Expected 0"
end
If your command might return something non-zero, and you expect that, it's cool:
line = Terrapin::CommandLine.new("/usr/bin/false", "", expected_outcodes: [0, 1])
begin
line.run
rescue Terrapin::ExitStatusError => e
# => You never get here!
end
You don't have the command? You get an exception:
line = Terrapin::CommandLine.new("lolwut")
begin
line.run
rescue Terrapin::CommandNotFoundError => e
e # => the command isn't in the $PATH for this process.
end
But don't fear, you can specify where to look for the command:
Terrapin::CommandLine.path = "/opt/bin"
line = Terrapin::CommandLine.new("lolwut")
line.command # => "lolwut", but it looks in /opt/bin for it.
You can even give it a bunch of places to look:
FileUtils.rm("/opt/bin/lolwut")
File.open('/usr/local/bin/lolwut') { |f| f.write('echo Hello') }
Terrapin::CommandLine.path = ["/opt/bin", "/usr/local/bin"]
line = Terrapin::CommandLine.new("lolwut")
line.run # => prints 'Hello', because it searches the path
Or just put it in the command:
line = Terrapin::CommandLine.new("/opt/bin/lolwut")
line.command # => "/opt/bin/lolwut"
You can see what's getting run. The 'Command' part it logs is in green for visibility! (where applicable)
line = Terrapin::CommandLine.new("echo", ":var", logger: Logger.new(STDOUT))
line.run(var: "LOL!") # => Logs this with #info -> Command :: echo 'LOL!'
Or log every command:
Terrapin::CommandLine.logger = Logger.new(STDOUT)
Terrapin::CommandLine.new("date").run # => Logs this -> Command :: date
Short version: Only pass user-generated data into the run
method and NOT
new
.
As shown in examples above, Terrapin will only shell-escape what is passed in as
interpolations to the run
method. It WILL NOT escape what is passed in to the
second argument of new
. Terrapin assumes that you will not be manually
passing user-generated data to that argument and will be using it as a template
for your command line's structure.
Terrapin will choose from among a couple different ways of running commands.
The simplest is Process.spawn
, which is also the default. Terrapin can also just use backticks, so if for some reason you'd prefer that, you can ask Terrapin to use that:
Terrapin::CommandLine.runner = Terrapin::CommandLine::BackticksRunner.new
And if you really want to, you can define your own Runner, though I can't imagine why you would.
Terrapin::CommandLine.runner = Terrapin::CommandLine::BackticksRunner.new
And if you really want to, you can define your own Runner, though I can't imagine why you would.
If you get Error::ECHILD
errors and are using JRuby, there is a very good
chance that the error is actually in JRuby. This was brought to our attention
in thoughtbot#24 and probably fixed in
http://jira.codehaus.org/browse/JRUBY-6162. You will want to use the
BackticksRunner
if you are unable to update JRuby.
If you get unsupported spawn option: out
warning (like in issue
38), try to use
PopenRunner
:
Terrapin::CommandLine.runner = Terrapin::CommandLine::PopenRunner.new
Terrapin should be thread safe. As discussed here, in this climate_control thread, climate_control, which modifies the environment under which commands are run for the BackticksRunner and PopenRunner, is thread-safe but not reentrant. Please let us know if you find this is ever not the case.
Security concerns must be privately emailed to security@thoughtbot.com.
Question? Idea? Problem? Bug? Comment? Concern? Like using question marks?
Thank you to all the contributors!
Terrapin is maintained and funded by thoughtbot, inc
The names and logos for thoughtbot are trademarks of thoughtbot, inc.
Copyright 2011-2018 Jon Yurek and thoughtbot, inc. This is free software, and may be redistributed under the terms specified in the LICENSE file.