How it arises in Flask
Webhooks and deploy hooks. A route receives JSON from a code host or a CI system and runs git, docker or a deploy script with fields from the payload. The author thinks of the sender as trusted. The route is a public URL, and unless the signature is verified first, anyone can post to it.
Wrappers around tools. Internal services expose ping, nslookup, ffmpeg, wkhtmltopdf or ImageMagick over HTTP. The value from request.args or request.form goes into the command line.
Upload handlers. request.files["file"].filename is client-chosen. Flask's upload pattern runs it through werkzeug.utils.secure_filename(), which keeps only ASCII letters, digits and ._- so the name is safe to join to a directory. That function is about paths. Its result can still be read as an option by the program that receives it, and it may be an empty string.
CLI commands reused in views. @app.cli.command() registers a Click command for operators. Calling the same function from a route moves operator-only string building onto the network.
Two details make Flask code specifically prone to the shell form. Examples are short, so shell=True with an f-string is the first thing that works. And quoting with shlex.quote() feels like a fix; it stops shell metacharacters, but a value such as --upload-pack=... is still a valid single token that git will act on.
Vulnerable and fixed
import subprocess
from flask import Flask, request
app = Flask(__name__)
@app.post("/hooks/deploy")
def deploy():
payload = request.get_json()
repo = payload["repository"]["clone_url"]
branch = payload["ref"]
subprocess.run(f"git clone --branch {branch} {repo} /srv/build", shell=True, check=True)
return {"status": "started"}
The fix removes the shell, allowlists the repository, restricts the branch to the characters a branch needs, and uses -- so nothing after it can be parsed as an option:
import re
import subprocess
from flask import Flask, abort, request
app = Flask(__name__)
REPOS = {"https://git.example.com/acme/site.git"}
BRANCH = re.compile(r"[A-Za-z0-9._/]{1,80}")
@app.post("/hooks/deploy")
def deploy():
payload = request.get_json()
repo = payload["repository"]["clone_url"]
branch = payload["ref"]
if repo not in REPOS or not BRANCH.fullmatch(branch) or branch.startswith("-"):
abort(400)
subprocess.run(["git", "clone", "--branch", branch, "--", repo, "/srv/build"], check=True)
return {"status": "started"}
What Flask and Python already do for you
Flask parses the body and gives you typed JSON, and its documentation teaches secure_filename() for every upload. Python's subprocess never invokes a shell unless asked, and its documentation says that with a list, shell metacharacters can safely be passed to child processes. shlex.quote() is there for the cases where a shell string is unavoidable. Verifying the webhook signature before doing anything else is the control that removes anonymous callers.
What es-python adds
For a webhook service the first question is simply "which programs does a request make us run, and with what". Run the app under es-python with the socket source on and replay a few real deliveries. Every program started with request bytes in its command line appears as a subprocess exec event, with the argument, the remote origin and the view that started it.
Sharper labels are added from the command as it is about to run, not from how the source reads. subprocess shell is the shell=True case: a shell is about to interpret request data. shell injection means an untrusted argument still contains a metacharacter. argument injection means one starts with -, which is the git case that shlex.quote() leaves open. These carry the command injection and argument injection impact tags, and data from the network is graded Critical.
The fixed view still starts git with request data, so its base event remains. That is deliberate: when EyalSec can prove the value could not leave its argument, the event is suppressed and kept out of the default view. To block, a Raise rule on the shell labels gives the view a RuntimeError before the process is created.
Review checklist
- Verify webhook signatures before reading the payload.
- Replace every
shell=Truewith a list. - Add
--before positional arguments that come from a request. - Treat
secure_filename()output as a path component, not as a safe argument. - Keep Click commands and view functions separate.
References
- Reference: Flask documentation: Uploading Files (checked 2026-10-12)
- Reference: Werkzeug documentation: Utilities, secure_filename() (checked 2026-10-12)
- Reference: Flask documentation: Command Line Interface (checked 2026-10-12)
- Reference: Python documentation: shlex.quote() (checked 2026-10-12)