Runtime

Avoiding Shell Injection in Python CLIs

How filenames, branch names and config values become commands in Python CLIs, and how argument lists, shlex.quote, -- and allow-lists shut each path down.

Updated

Internal tools are where shell injection hides. Nobody expects an attacker to run mytool archive, so a command like subprocess.run(f"tar czf {out} {src}", shell=True) ships without a second thought. Then someone points the tool at a directory created by a CI job, a file uploaded by a customer, a branch name from a pull request, or a value from a shared config file — and a string you treated as data is parsed as code. This guide explains exactly how that happens, then shows the layered fixes: argument lists so no shell is involved, -- so the called program cannot mistake data for options, shlex.quote for the rare case a shell is genuinely needed, and validation at the edge. It belongs to the subprocess topic.

Prerequisites

  • Python 3.10+ on Linux or macOS for the demonstrations (Windows notes are included).
  • A CLI that runs external programs, ideally through a helper like the one in calling external commands safely with subprocess.
  • An understanding that "the user" of a CLI includes every source of its input: arguments, files, environment, config and other programs' output.

How data becomes a command

With shell=True, Python runs /bin/sh -c <string>. Everything in that string is shell syntax. If any part of it came from outside your code, the author of that part is writing shell script alongside you.

How a filename becomes a command A malicious filename is interpolated into a shell command string, the shell parses the semicolon, and a second command runs with the user privileges. How a filename becomes a command User input x; rm -rf ~ f-string f"wc -l {name}" shell=True /bin/sh parses it Two commands wc, then rm interpolate hand off parse ; The shell cannot tell your quotes from the attacker's; an argument list never reaches a shell at all.

A concrete demonstration, safe to run in an empty directory:

# injection_demo.py — run in an empty scratch directory
import subprocess
from pathlib import Path

Path("notes.txt").write_text("hello\n")
name = "notes.txt; echo INJECTED > pwned.txt"

# Vulnerable: the semicolon ends the wc command and starts another.
subprocess.run(f"wc -l {name}", shell=True)
print("pwned.txt exists:", Path("pwned.txt").exists())   # True

Path("pwned.txt").unlink()

# Safe: one argument, no shell. wc just fails to find a strangely named file.
subprocess.run(["wc", "-l", name])
print("pwned.txt exists:", Path("pwned.txt").exists())   # False

The payload does not need a semicolon. $(...) and backticks run commands inside an argument, | pipes into another program, > overwrites files, & backgrounds a process, a newline starts a new command, and * expands to filenames. Filenames can legally contain every one of those characters on Linux.

Where untrusted input hides in internal tools

"But only our own engineers run this" is the usual defence, and it misses where the data comes from rather than who types the command. A few places that routinely feed hostile or merely unexpected strings into internal CLIs:

  • Branch and tag names. Anyone who can open a pull request chooses them, and CI jobs pass them to release scripts. Git allows $, ;, backticks and parentheses in ref names.
  • Filenames from elsewhere. Uploaded files, extracted archives, synced buckets and generated artefacts all carry names you did not pick.
  • Output of another program. A tool that reads a list of hostnames or container IDs from an API and passes them on is only as safe as that API's data.
  • Config in a repository. A .mytool.toml is edited by every contributor with commit rights.
  • Environment variables in CI. Pipeline variables are often settable by people who cannot change the pipeline itself.

In each case the person running the tool is trustworthy and the input is not. Security for a CLI is about the second, not the first.

The recipe: four layers

1. Argument lists, always

Pass a list and leave shell at its default of False. Python then calls execve() directly and each list element becomes exactly one element of the child's argv. There is no parser to confuse, so there is nothing to escape.

import subprocess
from pathlib import Path


def archive(src: Path, out: Path) -> None:
    subprocess.run(["tar", "-czf", str(out), "-C", str(src.parent), "--", src.name], check=True)

Most code that uses shell=True does so for a feature that has a direct Python equivalent:

Shell featureInstead, in Python
cmd1 | cmd2two Popen objects, stdout=PIPE into stdin=
> out.txtstdout=open("out.txt", "w")
*.logsorted(Path(".").glob("*.log"))
~/xPath.home() / "x"
$VARos.environ["VAR"] or env=
cd dir && cmdcwd="dir"
cmd1 && cmd2two run(..., check=True) calls

2. -- before untrusted positional data

An argument list stops the shell from interpreting your data. It does not stop the program from interpreting it. If a value starts with -, most programs treat it as an option. That is "argument injection", and it is real: git accepts --upload-pack=<command>, tar accepts --checkpoint-action=exec=<command>, find accepts -exec, and rsync accepts -e.

Option injection, even without a shell A terminal example where a filename beginning with a dash is interpreted as an option by the called program, and the double-dash separator prevents it. Option injection, even without a shell bash $ touch -- "--output=/etc/cron.d/x" # argv list, no shell — but the child still parses options: subprocess.run(["sort", name]) # sort sees --output=... subprocess.run(["sort", "--", name]) # "--" ends option parsing An argument list stops the shell; "--" stops the program from reading your data as a flag.

By POSIX convention, -- means "end of options; everything after this is a positional argument". Put it before any data you did not write. For programs that do not support --, make relative paths unambiguous by prefixing ./, which also works for filenames beginning with a dash:

def safe_path_arg(p: Path) -> str:
    s = str(p)
    return s if p.is_absolute() or not s.startswith("-") else f"./{s}"

Git is a special case worth knowing: a revision argument (git log <rev>) cannot be protected with --, because -- there separates revisions from paths. Validate revisions instead — git check-ref-format --branch for branch names, or git rev-parse --verify --end-of-options <rev> on git 2.24+.

3. When you truly need a shell: shlex.quote

Occasionally a shell is the point: you are generating a command for the user to copy, running a user-configured hook, or executing over SSH where the remote side always uses a shell. Then quote every interpolated value with shlex.quote, which wraps it in single quotes and escapes embedded single quotes so a POSIX shell sees exactly one word:

import shlex

host = "build-01"
path = "/srv/data/it's here; rm -rf /"
remote = f"du -sh -- {shlex.quote(path)}"
subprocess.run(["ssh", host, remote], check=True)
print(remote)   # du -sh -- '/srv/data/it'"'"'s here; rm -rf /'

Two caveats. shlex.quote targets POSIX shells; cmd.exe and PowerShell have entirely different rules and the standard library has no quoter for them — on Windows, avoid the shell. And quoting only helps if you quote every value; one forgotten interpolation undoes it. shlex.join(argv) quotes a whole list at once, which is the right way to print a command for the user.

Safe options when you need a shell Approaches for passing untrusted values to external commands ranked by safety: argument lists, shlex.quote, and plain string interpolation. Safe options when you need a shell Approach Safe with hostile input? Use when Argument list yes always, by default shlex.quote() yes, on POSIX shells you truly need pipes or globs Pass via env var yes a fixed script reads "$VAR" f-string + shell=True no never with outside data shlex.quote targets POSIX sh; cmd.exe has different rules and no standard-library quoter.

A cleaner alternative for fixed scripts is to pass values through the environment. The script text is a constant you wrote; the data never touches the command string:

subprocess.run(
    ["sh", "-c", 'cp -- "$SRC" "$DEST" && echo copied'],
    env={**os.environ, "SRC": user_src, "DEST": user_dest},
    check=True,
)

4. Validate at the edge

Finally, reject values that can never be legitimate before they reach a subprocess at all. If a parameter is a branch name, an environment name or a container tag, it has a known shape — enforce it where you parse arguments, as covered in advanced argument validation strategies:

import re

import typer

ENV_NAME = re.compile(r"^[a-z][a-z0-9-]{0,30}$")


def env_name(value: str) -> str:
    if not ENV_NAME.fullmatch(value):
        raise typer.BadParameter("use lowercase letters, digits and dashes")
    return value

Validation is defence in depth, not the primary control. Allow-lists are strong; deny-lists of "dangerous characters" always miss something.

UX considerations

Security fixes should not make the tool worse to use, and done well they make it better:

  • Filenames with spaces just work. Argument lists fix the most common user-facing bug of shell strings — paths with spaces or quotes — at the same time as the security hole.
  • Show commands in copy-pasteable form. When you log what you are about to run (for --verbose or --dry-run), print shlex.join(argv) so the user can paste it into a shell and get the same behaviour.
  • Explain rejected input. "invalid environment name 'prod;ls': use lowercase letters, digits and dashes" teaches the rule; "invalid input" does not.
  • Keep hooks explicit. If your tool runs user-configured shell commands (a post_build hook), document that the value is executed by a shell and is trusted configuration — and never interpolate other data into it.

Testing the behaviour

Injection tests are cheap and worth having as a permanent regression suite. Feed hostile values through the real code path and assert that nothing outside the intended operation happened:

# tests/test_injection.py
import subprocess
import sys
from pathlib import Path

import pytest

HOSTILE = [
    "a; touch PWNED",
    "$(touch PWNED)",
    "`touch PWNED`",
    "a | touch PWNED",
    "a\ntouch PWNED",
    "--output=PWNED",
]


def count_lines(path: str, cwd: Path) -> subprocess.CompletedProcess[str]:
    code = "import sys; print(len(open(sys.argv[1]).readlines()))"
    return subprocess.run([sys.executable, "-c", code, path], cwd=cwd,
                          capture_output=True, text=True)


@pytest.mark.parametrize("value", HOSTILE)
def test_hostile_names_are_just_names(tmp_path, value):
    count_lines(value, tmp_path)
    assert not (tmp_path / "PWNED").exists()


def test_real_file_with_awkward_name(tmp_path):
    p = tmp_path / "it's a file; really.txt"
    p.write_text("1\n2\n")
    assert count_lines(p.name, tmp_path).stdout.strip() == "2"

Add a lint rule so new shell=True calls are caught in review: Ruff's S602S605 rules (from flake8-bandit) flag subprocess calls with a shell and os.system. Enabling them is part of configuring Ruff for a CLI project.

Conclusion

Shell injection in a CLI is almost always the same mistake: building a command string from data and handing it to a shell. Remove the shell with argument lists and the whole class disappears; add -- before untrusted positionals to stop option injection; reserve shlex.quote or environment variables for the rare cases a shell is required; and validate structured values where they enter your program. Put a lint rule and a hostile-input test suite behind it so the fix stays fixed.

Frequently asked questions

Is shell=True safe if the string is a constant?

Yes. subprocess.run("make clean && make", shell=True) with no interpolated data is not injectable. The danger begins the moment any part of the string is computed. Many teams still ban it by lint rule and allow exceptions with an inline comment, which keeps the review question visible.

Does using pathlib.Path objects protect me?

Only in the sense that you are probably passing them in an argument list. A Path interpolated into an f-string is just text, and a path can contain any character except NUL and / in a component.

What about environment variables and config files as input?

Treat them exactly like arguments. A .mytool.toml in a repository is controlled by whoever can commit to that repository, which is why tools that run commands from project config — like pre-commit hooks — document that you are trusting the repository.

Is os.popen or commands.getoutput any different?

Both always use a shell. os.popen is a thin wrapper around subprocess.Popen(cmd, shell=True); commands no longer exists in Python 3. Replace them with subprocess.run and an argument list.