
Preventing Command Injection in Rust: Safely Running External Processes
What command injection looks like
Command injection happens when untrusted input changes the meaning of a command. The most dangerous pattern is constructing a string and handing it to a shell interpreter.
For example, this is unsafe:
use std::process::Command;
fn run_backup(target: &str) {
let cmd = format!("tar -czf backup.tgz {}", target);
let _ = Command::new("sh")
.arg("-c")
.arg(cmd)
.status();
}If target is docs; rm -rf /, the shell interprets ; as a command separator. Even if the input is not obviously malicious, spaces, quotes, glob characters, and variable expansion can change behavior in surprising ways.
The core lesson is simple: shell syntax is not data. If you allow user-controlled text to reach a shell, you are giving that text control over execution.
Prefer direct process execution over shells
Rust’s std::process::Command is designed to execute programs directly, without shell parsing. That is the default you want.
Safe pattern: pass arguments separately
use std::process::Command;
fn run_backup(target: &str) -> std::io::Result<()> {
let status = Command::new("tar")
.arg("-czf")
.arg("backup.tgz")
.arg(target)
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"backup command failed",
))
}
}Here, target is passed as a single argument. It cannot inject shell operators because no shell is involved. The tar program receives the literal string exactly as provided.
Why this matters
A shell expands and interprets:
- spaces and quoting
*,?, and[]glob patterns;,&&,||, and|$VARenvironment expansion- command substitution like `
...or$(...)`
Command::arg avoids all of that. Use it whenever possible.
Validate inputs before execution
Even without a shell, untrusted input can still be dangerous. A malicious argument may cause the target program to read or overwrite unintended files, connect to unexpected hosts, or behave in resource-intensive ways.
The right defense is to validate input against a narrow policy.
Example: allowlist a limited set of modes
Suppose your tool wraps git and only supports a few operations:
use std::process::Command;
enum GitAction {
Status,
Log,
Diff,
}
fn run_git(action: GitAction) -> std::io::Result<()> {
let mut cmd = Command::new("git");
match action {
GitAction::Status => {
cmd.arg("status");
}
GitAction::Log => {
cmd.arg("log").arg("--oneline").arg("-n").arg("20");
}
GitAction::Diff => {
cmd.arg("diff").arg("--stat");
}
}
let status = cmd.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other("git command failed"))
}
}This is safer than accepting arbitrary subcommands or flags. The application decides what is allowed, rather than forwarding user input blindly.
Validate by type, not just by string
Prefer structured inputs such as enums, numeric ranges, or parsed identifiers. For example:
- use
enumfor supported operations - use
u16for ports with range checks - use
PathBufonly after canonical policy checks - use regexes or parsers for constrained formats
The smaller the input surface, the easier it is to secure.
Avoid sh -c, cmd /C, and string concatenation
Sometimes developers reach for a shell because it seems convenient. It is almost always the wrong tradeoff for security-sensitive code.
Unsafe examples
use std::process::Command;
fn unsafe_search(term: &str) {
let command = format!("grep -R {} /var/log", term);
let _ = Command::new("sh").arg("-c").arg(command).status();
}use std::process::Command;
fn unsafe_windows(term: &str) {
let command = format!("dir {}", term);
let _ = Command::new("cmd").arg("/C").arg(command).status();
}These patterns are vulnerable because the shell parses the string. Even if you quote input manually, quoting rules differ across shells and platforms, and edge cases are easy to miss.
Safer alternative
use std::process::Command;
fn safe_search(term: &str) -> std::io::Result<()> {
let status = Command::new("grep")
.arg("-R")
.arg(term)
.arg("/var/log")
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other("grep failed"))
}
}If you need shell features like pipelines or globbing, consider implementing the logic in Rust instead. For example, read files with std::fs, filter lines in memory, and write results directly.
Control the executable path
Command injection is not only about arguments. If an attacker can influence which executable runs, they may redirect execution to a malicious binary.
Use absolute paths for trusted tools
use std::process::Command;
fn run_system_tool() -> std::io::Result<()> {
let status = Command::new("/usr/bin/rsync")
.arg("-a")
.arg("/data/source/")
.arg("/data/backup/")
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other("rsync failed"))
}
}Using an absolute path reduces ambiguity and avoids relying on PATH lookup. This is especially important for privileged services, setuid-like deployment contexts, and automation running with elevated permissions.
Be careful with PATH
If you call Command::new("tool"), Rust searches the process PATH. That may be acceptable in a controlled developer environment, but it is risky for production services if the environment is not tightly managed.
Best practices:
- prefer absolute paths for critical tools
- sanitize or define
PATHexplicitly when spawning child processes - avoid inheriting attacker-controlled environment variables
Manage environment variables deliberately
Child processes inherit environment variables by default. That can be useful, but it can also create security problems.
An attacker may exploit variables such as:
PATHLD_PRELOADon Unix-like systemsRUST_LOGor other app-specific settings- proxy variables like
HTTP_PROXY - locale variables that affect parsing
Clear or set the environment explicitly
use std::process::Command;
fn run_clean_env() -> std::io::Result<()> {
let status = Command::new("/usr/bin/env")
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("LANG", "C")
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other("env command failed"))
}
}Use env_clear() when the child process should not inherit the parent environment. Then add back only the variables it genuinely needs.
When environment inheritance is acceptable
For internal tools or local developer workflows, inheriting the environment may be fine. But for services handling untrusted input, assume the environment is part of the attack surface.
Handle arguments and filenames safely
A common misconception is that filenames are safe because they are “just paths.” In reality, filenames can contain spaces, leading dashes, Unicode oddities, and other characters that confuse downstream tools.
Treat filenames as opaque data
If you pass a filename to a program, pass it as a separate argument:
use std::process::Command;
fn compress_file(file: &str) -> std::io::Result<()> {
let status = Command::new("gzip")
.arg("--")
.arg(file)
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other("gzip failed"))
}
}The -- marker tells many Unix tools that subsequent values are operands, not flags. This prevents a filename like -n from being interpreted as an option.
Use -- where supported
Many command-line tools support -- to terminate option parsing. Use it whenever you forward user-controlled values that may begin with -.
| Risky input pattern | Safer handling |
|---|---|
tool {user_input} in a shell string | Command::new("tool").arg(user_input) |
Filename may start with - | Insert -- before operands |
| Arbitrary subcommands or flags | Map to an enum or allowlist |
| Inherited environment | env_clear() and set required variables |
| Relative executable lookup | Use an absolute path |
Capture output without exposing it to the shell
If you need to inspect command output, capture it directly from the process API rather than piping it through shell text processing.
use std::process::Command;
fn get_git_revision() -> std::io::Result<String> {
let output = Command::new("git")
.arg("rev-parse")
.arg("--short")
.arg("HEAD")
.output()?;
if !output.status.success() {
return Err(std::io::Error::other("git rev-parse failed"));
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(text)
}This avoids shell pipelines such as git rev-parse HEAD | cut -c1-7, which would require a shell and introduce parsing risk.
When processing output:
- check exit status
- handle UTF-8 failures explicitly
- avoid feeding output back into a shell
- limit output size if the command may produce large data
Design a secure command execution API
If you are building a library or internal framework, the API shape matters. A secure API makes unsafe behavior hard to express.
Good API design principles
- accept structured parameters, not raw command strings
- expose only approved executables
- separate command selection from argument data
- reject unknown flags or modes
- document whether environment variables are inherited
- return typed errors for validation failures
Example: a constrained wrapper
use std::process::Command;
pub struct ImageTool {
binary: &'static str,
}
impl ImageTool {
pub fn new() -> Self {
Self {
binary: "/usr/bin/convert",
}
}
pub fn resize(&self, input: &str, output: &str, width: u32) -> std::io::Result<()> {
if width == 0 || width > 10_000 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"width out of range",
));
}
let status = Command::new(self.binary)
.arg(input)
.arg("-resize")
.arg(format!("{width}x"))
.arg(output)
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other("image conversion failed"))
}
}
}This wrapper still needs input validation for file paths and operational limits, but it avoids shell execution and constrains the command surface.
Security checklist for Rust process spawning
Before shipping code that runs external commands, review the following:
- Never pass untrusted input to
sh -c,bash -c,cmd /C, or similar shells. - Prefer
Command::new(...).arg(...)over string concatenation. - Use absolute paths for sensitive executables.
- Validate user input with allowlists and typed constraints.
- Add
--before user-controlled operands when the tool supports it. - Clear or explicitly set environment variables for child processes.
- Avoid forwarding arbitrary flags or subcommands.
- Capture output directly instead of using shell pipelines.
- Treat filenames, URLs, and identifiers as opaque data.
- Fail closed when validation or execution fails.
