How to Fix a Path Traversal Vulnerability
A file-download endpoint that joins user input with os.path.join is vulnerable to path traversal - an attacker sends ../../../etc/passwd and reads arbitrary server files. The fix is two lines: resolve the real path with os.path.realpath(), then reject anything that escapes the uploads directory.
Why os.path.join is not enough
A typical file-serving endpoint looks like this:
UPLOAD_DIR = "/workspace/uploads"
@app.get("/files")
def download():
filename = request.args.get("name", "")
filepath = os.path.join(UPLOAD_DIR, filename) # unsafe
return send_file(filepath)
os.path.join does not strip .. segments. A request for name=../../../etc/passwd
produces the path /workspace/uploads/../../../etc/passwd, which the OS resolves to
/etc/passwd. If the process has read access, the attacker gets the file.
Reproduce it:
curl "http://localhost:8000/files?name=../../../etc/passwd"
On an unpatched endpoint that returns the file content, you have confirmed the vulnerability.
Fix: resolve then compare
Use os.path.realpath() to fully resolve the final path - it collapses .. segments and
follows symlinks - then check that the resolved path is still inside the allowed directory:
import os
from flask import Flask, request, jsonify, send_file
app = Flask(__name__)
UPLOAD_DIR = "/workspace/uploads"
@app.get("/files")
def download():
filename = request.args.get("name", "")
filepath = os.path.realpath(os.path.join(UPLOAD_DIR, filename))
upload_root = os.path.realpath(UPLOAD_DIR)
if not filepath.startswith(upload_root + os.sep) and filepath != upload_root:
return jsonify({"error": "Forbidden"}), 403
if not os.path.isfile(filepath):
return jsonify({"error": "File not found"}), 404
return send_file(filepath)
The startswith(upload_root + os.sep) check guards against a subtle edge case: without
the trailing separator, a directory named /workspace/uploads-extra would pass a naive
startswith("/workspace/uploads") check.
Verify the fix
# Legitimate file still works
curl "http://localhost:8000/files?name=report.pdf"
# Traversal attempt now returns 403
curl "http://localhost:8000/files?name=../../../etc/passwd"
Use the framework's safe helpers when possible
Flask's send_from_directory does this check for you:
from flask import send_from_directory
@app.get("/files")
def download():
filename = request.args.get("name", "")
return send_from_directory(UPLOAD_DIR, filename) # raises 404 on traversal
Express has res.sendFile(name, { root: uploadsDir }). These helpers exist precisely
because manual path construction is error-prone. The vulnerability only appears when
code bypasses them and joins user input into a path without validation.
Defense in depth
Resolving and comparing the path is the core fix, but layer a few more controls so one mistake isn't fatal:
- Never pass user input straight to the filesystem. Where you can, look the file up by an id that maps to a known-safe path, rather than accepting a filename at all.
- Allowlist, don't blocklist. Stripping
../is a losing game - encodings like%2e%2e%2fand absolute paths slip through. Resolve the real path and confirm it starts with your intended base directory; reject everything else. - Drop privileges. Run the service as a user that can only read the upload directory, so even a successful traversal reaches nothing like
/etc/passwdor app secrets. - Use the framework helper. Flask's
send_from_directory, Django'ssafe_join, and equivalents implement the safe-join check for you - prefer them over hand-rolled path math.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Reproducing a path traversal with a ../../../etc/passwd request
- Fixing the endpoint with os.path.realpath() and a startswith check
- Using send_from_directory as the safe alternative to manual path joining
FAQ
What is a path traversal vulnerability?
A path traversal (also called directory traversal) vulnerability lets an attacker supply a filename like ../../../etc/passwd that, when joined with a base directory, resolves to a file outside the intended folder. If the server reads and returns it, the attacker can access any file the process can read.
Why doesn't os.path.join prevent path traversal?
os.path.join just concatenates path components - it does not strip or resolve .. segments. Traversal still works because the OS resolves the .. segments at open() time. You need os.path.realpath() to collapse them before the check.
How do I safely serve user-requested files in Flask?
Use Flask's send_from_directory(directory, filename) - it calls realpath internally and raises a 404 if the resolved path escapes the base directory. If you need custom logic, call os.path.realpath on the joined path and verify it starts with os.path.realpath(UPLOAD_DIR) + os.sep before serving.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →