Flask · Command injection

Command injection in Flask

Flask is the framework people reach for when they need a small HTTP front for a command-line tool, which makes "take a request, run a program" the most common Flask view that goes wrong.

Class
Command injection in Python
CWE
CWE-78, CWE-88
web framework
Flask documentation
On this page

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

  1. Verify webhook signatures before reading the payload.
  2. Replace every shell=True with a list.
  3. Add -- before positional arguments that come from a request.
  4. Treat secure_filename() output as a path component, not as a safe argument.
  5. Keep Click commands and view functions separate.

References

See it on your own Flask code

es-python runs your application unchanged and reports when request data reaches the operation, with where the data came from and the line that used it. A live demo walks through it on a real application.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo