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.

Security Engineerpythonflaskpath-traversal

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:

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

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

Close a SQL Injection in a Search EndpointSecurity projectNeutralize a Stored XSS in CommentsSecurity projectRemove Hardcoded Credentials From Source CodeSecurity projectSecurity roadmapStep by step to hiredSecurity interview questionsSTAR answersAll Security projectsProjects hub

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 →