Skip to content

Serving files

Severity: mediumApplies to: Flask 2.xApplies to: Flask 3.1.3Applies to: Werkzeug 3.1.8Facts last verified 2026-08-13 against Flask 3.1.3 · Werkzeug 3.1.8

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.

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') -> None
safe_join('static', '/etc/hostname') -> None
safe_join('static', 'a/../../b') -> None

Now 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.

settings on this page
USE_X_SENDFILE

Hands 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
Read in: Flask 3.1.3 app.config, read from a running app
SEND_FILE_MAX_AGE_DEFAULT

None 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
Read in: Flask 3.1.3 app.config, read from a running app
the fix
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 work

If 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 Path
from 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.

verify it workedRun this in: shell
Terminal window
# every send_file call, and where its argument comes from
grep -rn 'send_file(' --include='*.py' . | head -20

A 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:

Terminal window
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")"
done

404 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.

before you ship this

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:

Terminal window
grep -rn 'send_file(' --include='*.py' . -A2 | grep -oE '"[^"]*"|/[a-z/._-]+' | sort -u | head -20

If several base directories are legitimately in use, use send_from_directory per directory rather than falling back to send_file for the awkward one.