FastAPI · Path traversal

Path traversal in FastAPI

FastAPI will happily deliver a full file path to your function as a validated str, so a file endpoint built from the tutorial's {file_path:path} parameter and a FileResponse needs its own containment check.

Class
Path traversal in Python
CWE
CWE-22
web framework
FastAPI documentation
On this page

How it arises in FastAPI

The path convertor. FastAPI's path-parameter tutorial shows /files/{file_path:path}: the :path suffix tells the router the parameter should match any path, slashes included. The example returns the value. Real endpoints open it.

FileResponse takes a path. FileResponse(path) asynchronously streams that file. Like any response class it trusts its caller. There is no directory argument and nothing to compare the path against.

UploadFile.filename. The upload tutorial returns {"filename": file.filename}. That attribute is the client's file name, unmodified. Writing to UPLOAD_DIR / file.filename lets the client choose where the bytes land, and pathlib has the same absolute-path behaviour as os.path.join: Path("/srv/up") / "/etc/cron.d/x" is /etc/cron.d/x.

Query parameters for exports. GET /export?file=2026-10.csv declared as file: str. Type validation passes for any string.

Type validation is orthogonal. A str path parameter is valid whatever it contains. Reviewers who rely on "FastAPI validates inputs" often skip file endpoints for that reason.

Dataset and model stores. ML services read datasets/{name} or models/{version} from disk, with the name taken from a request body field.

Vulnerable and fixed

from pathlib import Path

from fastapi import FastAPI, UploadFile
from fastapi.responses import FileResponse

app = FastAPI()
STORE = Path("/srv/app/store")


@app.get("/files/{file_path:path}")
def download(file_path: str):
    return FileResponse(STORE / file_path)


@app.post("/files")
async def upload(file: UploadFile):
    (STORE / file.filename).write_bytes(await file.read())
    return {"saved": file.filename}

For downloads, resolve the candidate and require it to stay inside the store. For uploads, do not use the client's name as a path at all: generate one, and return it:

import uuid
from pathlib import Path

from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse

app = FastAPI()
STORE = Path("/srv/app/store").resolve()


@app.get("/files/{file_path:path}")
def download(file_path: str):
    target = (STORE / file_path).resolve()
    if not target.is_relative_to(STORE) or not target.is_file():
        raise HTTPException(status_code=404)
    return FileResponse(target)


@app.post("/files")
async def upload(file: UploadFile):
    name = uuid.uuid4().hex
    (STORE / name).write_bytes(await file.read())
    return {"saved": name, "original": file.filename}

If the files are public and need no per-request logic, skip the endpoint: app.mount("/static", StaticFiles(directory="static"), name="static").

What FastAPI and Starlette already do for you

StaticFiles serves one directory and does the containment work itself; its follow_symlink option defaults to False, so a symbolic link inside the directory does not become a way out. Ordinary path parameters (without :path) match a single segment. UploadFile keeps content in a spooled file and never writes to a client-chosen location by its own action. Typed parameters let you replace names with integers or UUIDs, which removes the path from the request entirely.

What es-python adds

What a FastAPI file endpoint looks like under es-python: every request that reaches FileResponse or open() with a client-supplied path records an io open event carrying that path, and when the path has a .. segment or is absolute, a path traversal event fires with it. The second one has the path traversal impact tag and is graded Critical when the data came from the network. The event carries the call stack at the open, so you can see which endpoint or background task performed it.

On uploads the write is what matters. es-python grades a write as a disclosure signal: it records where the data went and whether other users on the machine can read that file. The vulnerable upload shows a write whose destination was chosen by the request; the fixed upload writes to a name generated in the process, so the request no longer steers the path.

Know the limits. Matching is byte-level and POSIX-only and does not decode percent-encoding itself; the router decodes the URL, so the check sees the same decoded string your code passes on. The .. test is on the value, not on the resolved result, so a symlink inside the store that points outside is not flagged; that is what resolve() in the fix and follow_symlink=False are for.

The Block backend network attacks template, or any Raise rule on path traversal, raises a RuntimeError before the file is opened, which your exception handlers turn into an error response.

Review checklist

  1. Search for :path} in route declarations and for FileResponse(.
  2. Search for .filename used in a path expression.
  3. Add resolve() plus is_relative_to() to every hand-built file endpoint.
  4. Mount StaticFiles for public assets.
  5. Identify stored files by generated ids.

References

See it on your own FastAPI 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