Serving files
Flask has two file-serving functions and only one of them is safe with untrusted input. The difference is not documented as a security boundary, and the names do not suggest one.
Observed
Section titled “Observed”send_from_directory("static", name) behind a <path:name> route:
| Requested | Result |
|---|---|
ok.txt |
200, serves the file |
../secret.txt |
404 |
..%2fsecret.txt |
404 |
....//secret.txt |
404 |
%2e%2e%2fsecret.txt |
404 |
Every traversal attempt is refused. send_from_directory routes through Werkzeug’s
safe_join, which returns None for anything escaping the base:
safe_join('static', 'ok.txt') -> 'static/ok.txt'safe_join('static', '../secret.txt') -> Nonesafe_join('static', '/etc/hostname') -> Nonesafe_join('static', 'a/../../b') -> NoneNow the same request shape through send_file(request.args["p"]):
| Requested | Result |
|---|---|
static/ok.txt |
200, serves the file |
secret.txt |
200, serves a file outside static/ |
send_file takes a path and sends it. There is no base directory, so there is nothing
to be contained by and no check to perform.
So the finding is not “Flask has a path traversal”. It is that the safe helper is
genuinely safe, and reaching past it is what creates the hole — usually because
send_file looked like the simpler function.
USE_X_SENDFILEHands the file off to the front-end server via X-Sendfile instead of streaming it through Python. Worth knowing when hardening file serving: with it on, the path your code resolved is handed to another process to open, so whatever containment send_from_directory gave you has to hold before that point — and it does nothing at all unless the front-end server is configured to honour the header.
- accepts
True|False- default
False- set in
app.config
SEND_FILE_MAX_AGE_DEFAULTNone means Flask sends no far-future Cache-Control and relies on conditional requests instead. Relevant here because caching a file that turned out to be sensitive extends the exposure past the fix.
- accepts
None|any int, seconds|timedelta- default
None- set in
app.config
from flask import send_from_directory
@app.get("/files/<path:name>")def download(name): return send_from_directory(UPLOAD_DIR, name) # safe_join does the workIf you must use send_file — because the path comes from a database row rather than
from the URL — join and verify it yourself:
from pathlib import Pathfrom flask import abort, send_file
BASE = Path(UPLOAD_DIR).resolve()
@app.get("/files/<int:file_id>")def download(file_id): row = get_file_or_404(file_id) target = (BASE / row.stored_name).resolve() if not target.is_relative_to(BASE): # Python 3.9+ abort(404) return send_file(target).resolve() before the comparison is what makes it correct — it collapses .. and
follows symlinks, so a stored name that escapes is caught rather than normalised after
the check.
# every send_file call, and where its argument comes fromgrep -rn 'send_file(' --include='*.py' . | head -20A send_file whose argument is a literal or a fully-derived internal path is fine.
One taking request.args, request.form, a route parameter or an unvalidated database
column is the finding.
Then probe the live endpoint:
for p in '../../etc/hostname' '..%2f..%2fetc%2fhostname' '....//....//etc/hostname'; do printf '%-32s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "https://app.example.com/files/$p")"done404 on all three. A 200 with content is an arbitrary file read.
The filename is attacker-controlled on the way in, too
Section titled “The filename is attacker-controlled on the way in, too”This page is about reading, but the same value usually arrives during an upload.
werkzeug.datastructures.FileStorage.filename is whatever the client put in the
multipart part, and it is not sanitised for you.
Use secure_filename before it touches the filesystem, and know its limits — it strips
directory separators and non-ASCII, which means two different uploads can normalise to
the same name and silently overwrite each other:
from werkzeug.utils import secure_filename
name = secure_filename(upload.filename) or "unnamed"Storing under a generated id and keeping the original name as metadata avoids both problems at once, and it is what most applications end up doing anyway.
Switching send_file to send_from_directory breaks any path that was legitimately
outside the base directory — a shared media mount, a symlinked storage volume, a
tempfile written elsewhere.
safe_join refuses symlinks that leave the base, so a directory that looks inside and
resolves outside starts returning 404 after the change. That is the control working, and
it will look like missing files.
Enumerate what is actually being served before switching:
grep -rn 'send_file(' --include='*.py' . -A2 | grep -oE '"[^"]*"|/[a-z/._-]+' | sort -u | head -20If several base directories are legitimately in use, use send_from_directory per
directory rather than falling back to send_file for the awkward one.
Related
Section titled “Related”- Request size limits — the other half of handling uploads
- Template injection — the other place a request value becomes something executable