Command injection defense
The moment you assemble a command as a string and hand it to a shell, every
metacharacter in it becomes live: a semicolon, backtick, $(), or pipe in
user input runs a second command of the attacker's choosing. Quoting and
escaping are a losing arms race against shell parsing rules. The durable fix
is to never involve a shell: pass the program and its arguments as a list so
the operating system executes exactly one binary with exactly those args.
Method
- Pass an argv array, never a command string. Use
subprocess.run(["convert", src, dst]) in Python, execFile or
spawn with an args array in Node, exec.Command("git", "clone", url)
in Go. The arguments reach the program verbatim, so shell metacharacters
are inert data.
- Keep
shell=True and its equivalents out. subprocess with
shell=True, Node exec, Ruby backticks, and system("string") all
invoke /bin/sh on your string. If you did not type a literal command,
these must not appear in the diff.
- Never build the string even for the array. Do not do
["sh", "-c", f"convert {name}"]: that reintroduces the shell you just
avoided. Put each value in its own array slot so the program, not a
shell, receives it.
- Separate options from operands with
--. Insert -- before any
user-controlled path or value so a filename beginning with - is treated
as an operand, not a flag. This stops argument injection, the quieter
cousin where input becomes an unintended option.
- Validate the value against an allowlist when it selects behavior.
When input picks a subcommand, a branch name, or a mode, match it against
a fixed set of permitted values rather than passing arbitrary text, even
through an array.
- Prefer a native library over shelling out. Resolve a hostname with
the DNS library, not
dig; manipulate images with a binding, not a
convert subprocess. No child process means no command to inject into.
Checks
- Does every process launch pass an argument list, with zero
shell=True,
exec(), backticks, or sh -c on a built string?
- Would input of
; rm -rf / or $(curl evil) land as a literal argument,
doing nothing?
- Is
-- present before user-controlled operands that could look like
flags?
- Where a native library exists, is the subprocess justified rather than
reached for by habit?
Boundaries
This addresses OS command execution. Injection into SQL, LDAP, XML, or
template engines is the same disease in a different interpreter and needs
that interpreter's parameterized interface. Where a value genuinely must
reach a shell feature like a pipeline, build the pipeline in code with
connected process handles rather than a shell string.
1---2name: command-injection-defense3description: Run external programs without ever building a shell string from untrusted input, using argument arrays that bypass the shell entirely. Use when a program shells out to another binary, especially with any value that came from a user, a file, or the network.4---56# Command injection defense78The moment you assemble a command as a string and hand it to a shell, every9metacharacter in it becomes live: a semicolon, backtick, `$()`, or pipe in10user input runs a second command of the attacker's choosing. Quoting and11escaping are a losing arms race against shell parsing rules. The durable fix12is to never involve a shell: pass the program and its arguments as a list so13the operating system executes exactly one binary with exactly those args.1415## Method16171. **Pass an argv array, never a command string.** Use18 `subprocess.run(["convert", src, dst])` in Python, `execFile` or19 `spawn` with an args array in Node, `exec.Command("git", "clone", url)`20 in Go. The arguments reach the program verbatim, so shell metacharacters21 are inert data.222. **Keep `shell=True` and its equivalents out.** `subprocess` with23 `shell=True`, Node `exec`, Ruby backticks, and `system("string")` all24 invoke `/bin/sh` on your string. If you did not type a literal command,25 these must not appear in the diff.263. **Never build the string even for the array.** Do not do27 `["sh", "-c", f"convert {name}"]`: that reintroduces the shell you just28 avoided. Put each value in its own array slot so the program, not a29 shell, receives it.304. **Separate options from operands with `--`.** Insert `--` before any31 user-controlled path or value so a filename beginning with `-` is treated32 as an operand, not a flag. This stops argument injection, the quieter33 cousin where input becomes an unintended option.345. **Validate the value against an allowlist when it selects behavior.**35 When input picks a subcommand, a branch name, or a mode, match it against36 a fixed set of permitted values rather than passing arbitrary text, even37 through an array.386. **Prefer a native library over shelling out.** Resolve a hostname with39 the DNS library, not `dig`; manipulate images with a binding, not a40 `convert` subprocess. No child process means no command to inject into.4142## Checks4344- Does every process launch pass an argument list, with zero `shell=True`,45 `exec()`, backticks, or `sh -c` on a built string?46- Would input of `; rm -rf /` or `$(curl evil)` land as a literal argument,47 doing nothing?48- Is `--` present before user-controlled operands that could look like49 flags?50- Where a native library exists, is the subprocess justified rather than51 reached for by habit?5253## Boundaries5455This addresses OS command execution. Injection into SQL, LDAP, XML, or56template engines is the same disease in a different interpreter and needs57that interpreter's parameterized interface. Where a value genuinely must58reach a shell feature like a pipeline, build the pipeline in code with59connected process handles rather than a shell string.