Interacting with Shell Commands in Ruby
We often need to run shell commands from within Ruby code, and Ruby provides a number of ways to do that. It’s sligtly confusing at first - specifically because there are different ways of accomplishing the same thing. I found this useful flowchart on StackOverflow that helps you choose the appropriate option. If you’re looking for a detailed writeup of the different options I recommend reading 6 Ways to Run Shell Commands in Ruby by Nate Murray.
One common mistake I’ve seen - and made myself - is to use string interpolation when passing arguments to a shell command. For example, in my
recent post on Tweeting new Jekyll Posts I was using Ruby to interact with the git
command. I could have written:
# Git add and commit (unsafe example)
commit_message = "Blogged: #{document.title} #{URI.join(site_config.fetch('url'), document.url)}"
`git add #{post_path}`
`git commit -m "#{commit_message}"`
This is a bad idea because
- Can break if it includes double quotes, ampersands, semicolons, etc.
- Can lead to command injection if the title includes malicious input.
We could get around (1) by using Shellwords.escape
and you could argue I don’t have to worry about (2) because I control the input. However, best practice dictates that you should never interpolate any arguments into shell commands. Instead you should use system
and pass the arguments as parameters.
# Git add and commit
commit_message = "Blogged: #{document.title} #{URI.join(site_config.fetch('url'), document.url)}"
system('git', 'add', post_path)
system('git', 'commit', '-m', commit_message)
This principle doesn’t just apply to Ruby - I had the exact same issue with Java code I wrote recently. Instead of building the entire command:
String command = String.format(
"pg_dump --dbname=%s --host=%s --port=%s --username=%s",
connectionProperties.dbName(),
connectionProperties.host(),
connectionProperties.port(),
connectionProperties.username()
);
ProcessBuilder processBuilder = new ProcessBuilder("bash", "-c", command);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
Pass the arguments as parameters:
List<String> pgDumpCommand =
List.of(
"pg_dump",
"--dbname",
connectionProperties.dbName(),
"--host",
connectionProperties.host(),
"--port",
connectionProperties.port().toString(),
"--username",
connectionProperties.username()
);
ProcessBuilder processBuilder = new ProcessBuilder(
Stream.of(pgDumpCommand, options).flatMap(Collection::stream).toList()
);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
In this example I also needed to allow the consumers of this code (which I control) to pass additional options. In that case I would also suggest that you use an explicit whitelist/allowlist for accepted options.