
Preventing Command Injection in Rust with Safe Process Execution
Why command injection still matters in Rust
Command injection happens when untrusted input changes the meaning of a command. A classic example is building a shell string like:
sh -c "grep <user_input> /var/log/app.log"If <user_input> contains shell metacharacters, the shell may interpret them as additional commands. Rust does not prevent this because the vulnerability is not about memory corruption; it is about unsafe process construction.
This issue appears in:
- admin tools that wrap system utilities
- web backends that call
git,ffmpeg,convert, ortar - automation scripts that process user-supplied filenames or filters
- desktop apps that launch helper binaries
The safest approach is to avoid the shell entirely and pass arguments directly to a program.
The core rule: do not invoke a shell unless you must
Rust’s std::process::Command is designed to launch programs without shell parsing. That means each argument is passed as a separate value, not concatenated into a command line string.
Unsafe pattern: shell string construction
The following pattern is risky because it delegates parsing to a shell:
use std::process::Command;
fn search_logs(user_query: &str) {
let cmd = format!("grep {} /var/log/app.log", user_query);
let _ = Command::new("sh")
.arg("-c")
.arg(cmd)
.status();
}If user_query is error; rm -rf /, the shell may execute the second command.
Safe pattern: direct argument passing
use std::process::Command;
fn search_logs(user_query: &str) -> std::io::Result<String> {
let output = Command::new("grep")
.arg(user_query)
.arg("/var/log/app.log")
.output()?;
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}Here, grep receives the query as a literal argument. Characters like ;, &&, |, and $() lose their special meaning because no shell is involved.
Prefer structured arguments over formatted strings
A common mistake is to build a single string and then split it later. This is fragile and can still create injection opportunities.
Bad: string concatenation
let pattern = format!("--pattern={}", user_input);
Command::new("tool").arg(pattern);This may be acceptable if tool expects a single argument, but it is often better to separate the option name and value:
Command::new("tool")
.arg("--pattern")
.arg(user_input);This makes the intent explicit and avoids ambiguity if the value begins with - or contains whitespace.
Better: use typed configuration
If your application has multiple process options, model them as structured data rather than raw strings:
struct SearchJob<'a> {
query: &'a str,
path: &'a str,
case_sensitive: bool,
}Then map each field to a specific command argument. This reduces the chance of accidentally feeding untrusted input into a shell-like interface.
Validate inputs before launching a process
Even when you avoid the shell, you should still validate user input. Safe argument passing prevents command injection, but it does not guarantee that the target program will behave safely with arbitrary values.
Validate by allowlist
The best validation strategy is usually an allowlist. For example, if your tool accepts a log level, only permit known values:
fn parse_level(input: &str) -> Option<&'static str> {
match input {
"debug" => Some("debug"),
"info" => Some("info"),
"warn" => Some("warn"),
"error" => Some("error"),
_ => None,
}
}Validate paths carefully
If a user provides a file path, ensure it is within an expected directory and does not escape via .. segments or symlinks. For example, if your app processes files under /srv/uploads, normalize and verify the resolved path before using it.
Validate length and character set
Some programs behave poorly with extremely long inputs or unusual Unicode forms. Consider rejecting:
- empty values
- excessively long strings
- control characters
- embedded NUL bytes
- unexpected whitespace
Rust strings cannot contain NUL bytes in the middle of a CString, but many OS APIs and external tools still have edge cases worth handling explicitly.
Use Command correctly
Rust’s Command API provides several methods that help you launch processes safely.
| Method | Purpose | Security note |
|---|---|---|
Command::new | Select executable | Prefer an absolute path when possible |
.arg() | Add one argument | Avoid building shell strings |
.args() | Add multiple arguments | Good for fixed argument lists |
.current_dir() | Set working directory | Ensure the directory is trusted |
.env() / .env_clear() | Control environment | Clear or minimize inherited variables |
.output() | Capture stdout and stderr | Check exit status before trusting output |
.status() | Wait for completion | Use when output is not needed |
Example: safe wrapper around git
Suppose you want to inspect a repository status from a web service. Do not pass user input into a shell. Instead, use a fixed executable and explicit arguments:
use std::process::Command;
fn git_status(repo_dir: &str) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("/usr/bin/git")
.arg("-C")
.arg(repo_dir)
.arg("status")
.arg("--short")
.env_clear()
.output()?;
if !output.status.success() {
return Err(format!(
"git failed: {}",
String::from_utf8_lossy(&output.stderr)
)
.into());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}This example still needs path validation for repo_dir, but it avoids shell injection and reduces environmental surprises by clearing inherited variables.
Control the execution environment
The environment can influence how a program behaves. Variables such as PATH, HOME, LANG, IFS, LD_PRELOAD, and tool-specific settings may change execution in ways that affect security.
Use absolute executable paths
If you call Command::new("git"), the OS may search PATH. That can be acceptable in trusted environments, but in security-sensitive code, prefer an absolute path:
Command::new("/usr/bin/git")This avoids accidentally executing a malicious binary earlier in PATH.
Clear or set environment variables explicitly
If the child process does not need the parent’s environment, remove it:
use std::process::Command;
let output = Command::new("/usr/bin/ffmpeg")
.env_clear()
.env("LANG", "C")
.arg("-version")
.output()?;This is especially useful for tools that may behave differently based on locale or inherited configuration files.
Set a trusted working directory
Some programs resolve relative paths based on the current directory. Use .current_dir() to control this behavior:
Command::new("/usr/bin/tar")
.current_dir("/srv/backups")
.arg("-czf")
.arg("archive.tgz")
.arg("data")
.status()?;If the directory is user-controlled, validate it first. A malicious working directory can still cause problems through relative path resolution.
Handle output safely
Capturing a child process’s output is common, but the output itself should be treated as untrusted data.
Check exit status
Never assume success just because a process produced output:
let output = Command::new("grep")
.arg("error")
.arg("/var/log/app.log")
.output()?;
if !output.status.success() {
return Err("search failed".into());
}Avoid reusing output as code
Do not feed command output into another shell command. If you must chain tools, pass data through files, pipes, or structured formats instead of command strings.
Limit output size
A malicious or buggy process can emit huge amounts of data. If you need to process untrusted output, consider streaming it rather than loading everything into memory. For long-running tools, also set timeouts at a higher level using threads, async runtimes, or platform-specific process control.
Common anti-patterns and safer alternatives
| Anti-pattern | Risk | Safer alternative |
|---|---|---|
sh -c format!(...) | Shell injection | Command::new(...).arg(...).arg(...) |
| Concatenating user input into one string | Argument confusion | Pass each argument separately |
Using PATH for sensitive executables | Binary hijacking | Use absolute paths |
| Inheriting all environment variables | Unexpected behavior | env_clear() and explicit env values |
| Trusting process output blindly | Logic bugs, data abuse | Check exit code and validate output |
| Accepting arbitrary paths | Traversal and file abuse | Canonicalize and allowlist directories |
A practical pattern for safe process execution
A good design separates three responsibilities:
- Input validation
Confirm that the user input matches the expected format.
- Command construction
Build the process invocation with Command, one argument at a time.
- Result handling
Check exit status, parse output carefully, and surface errors clearly.
Here is a compact example that combines these steps:
use std::process::Command;
fn run_thumbnailer(input: &str, size: u32) -> Result<(), String> {
if input.is_empty() || input.len() > 256 {
return Err("invalid input path length".into());
}
if size == 0 || size > 4096 {
return Err("invalid size".into());
}
let status = Command::new("/usr/bin/convert")
.env_clear()
.arg(input)
.arg("-resize")
.arg(format!("{}x{}", size, size))
.arg("thumbnail.png")
.status()
.map_err(|e| e.to_string())?;
if !status.success() {
return Err(format!("convert exited with {}", status));
}
Ok(())
}This example is not complete production code, but it demonstrates the right shape: validate first, avoid the shell, and check the result.
When shell usage is unavoidable
Sometimes you genuinely need shell features such as globbing, pipelines, redirection, or compound expressions. Even then, treat shell invocation as a high-risk boundary.
If you must use a shell:
- keep the shell script minimal
- avoid interpolating untrusted input directly
- pass data through environment variables or temporary files when appropriate
- quote carefully according to the target shell’s rules
- prefer a dedicated library or native Rust implementation instead
In many cases, the shell can be replaced entirely with Rust code or a safer library. For example, file globbing, text processing, and archive handling often have native crates that remove the need for shell syntax.
Production checklist
Before shipping code that launches external processes, verify the following:
- no
sh -c,cmd /C, or equivalent shell wrapper unless absolutely necessary - every user-controlled value is passed as a separate argument
- executable paths are explicit and trusted
- environment variables are minimized
- paths are validated and constrained
- exit status is checked
- output is treated as untrusted
- resource usage is bounded where possible
If you apply these rules consistently, Rust’s process API becomes a strong foundation for secure integration with system tools.
