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
- Search for
:path}in route declarations and forFileResponse(. - Search for
.filenameused in a path expression. - Add
resolve()plusis_relative_to()to every hand-built file endpoint. - Mount
StaticFilesfor public assets. - Identify stored files by generated ids.
References
- Reference: FastAPI documentation: Path Parameters, path convertor (checked 2026-10-12)
- Reference: FastAPI documentation: Custom Response, FileResponse (checked 2026-10-12)
- Reference: FastAPI documentation: Request Files (checked 2026-10-12)
- Reference: Starlette documentation: Static Files (checked 2026-10-12)