Resolve both paths and compare whole components before building the FileResponse: base = Path(base_dir).resolve(), target = (base / name).resolve(), and serve target only if target.is_relative_to(base). resolve() collapses ../ and follows symlinks, and the check also catches absolute names, which replace base in the / operator. Do not strip "../" or compare strings with startswith.
- Find every FileResponse, open(), Path.read_bytes() or read_text() and os.path.join() whose path includes request data: query and path parameters, form fields and UploadFile.filename.
- Resolve the base directory and the requested path with Path.resolve(); it follows symlinks, and with the default strict=False it does not fail for a missing file.
- Return the file only if target.is_relative_to(base), pass the resolved target to FileResponse, and answer the same 404 for refused and missing paths so the response does not reveal what exists outside the folder.
- Check target.is_file() before returning FileResponse: it opens the file only while sending and raises RuntimeError for a missing path or a directory, which becomes a 500.
- Reject an empty path and a NUL byte with 400 before touching the file system: pathlib raises ValueError on a NUL byte.
- For a folder of public files, mount StaticFiles(directory=...) instead of writing the route, and leave follow_symlink at its default, False.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def user_file(base_dir: str, requested_path: str) -> Path | None:
return Path(base_dir) / requested_pathWith IMPL=vulnerable, 5 of 12 tests fail (5 exploit tests).
The fix
def user_file(base_dir: str, requested_path: str) -> Path | None:
# resolve() returns an absolute path with ../ collapsed and every symlink followed.
# Resolve the base the same way: on macOS, for example, /var is a link to /private/var.
base = Path(base_dir).resolve()
# The / operator lets an absolute requested_path replace base; the check below catches it.
target = (base / requested_path).resolve()
# is_relative_to() compares whole path components, so /srv/files-secret is not inside /srv/files.
if not target.is_relative_to(base):
return None
# FileResponse gets the resolved path, so the file it opens is the one that was checked.
return targetWith IMPL=fixed, 12 of 12 tests pass.
A fix that looks safe but is not
def user_file(base_dir: str, requested_path: str) -> Path | None:
cleaned = requested_path.replace("../", "")
return Path(base_dir) / cleanedWith IMPL=wrong, 3 of 12 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: each payload aims at a real file outside files/, so a leak would come back as a 200.
def test_exploit_dot_dot_slash_cannot_climb_out_of_the_base_directory(client):
assert_not_served(get(client, "../secret.txt"))
def test_exploit_percent_encoded_dot_dot_slash_is_decoded_by_starlette_and_still_refused(client):
assert_not_served(client.get("/files?path=%2e%2e%2fsecret.txt"))
def test_exploit_absolute_path_cannot_replace_the_base_directory(client, tree):
assert_not_served(get(client, str(tree / "secret.txt")))
def test_exploit_backslashes_cannot_climb_out_a_separator_on_windows(client):
assert_not_served(get(client, "..\\secret.txt"))
def test_exploit_sibling_folder_that_shares_the_prefix_is_outside(client):
# files-secret starts with "files" but is not inside files/.
assert_not_served(get(client, "../files-secret/api-keys.txt"))
def test_exploit_symlink_inside_the_base_cannot_lead_outside_it(client, tree, base_dir):
(base_dir / "shared").symlink_to(tree / "files-secret", target_is_directory=True)
assert_not_served(get(client, "shared/api-keys.txt"))
def test_exploit_four_dots_do_not_turn_back_into_dot_dot_slash(client):
# A single-pass filter that deletes "../" turns "....//" into "../".
assert_not_served(get(client, "....//secret.txt"))- FileResponse(path) serves whatever path it is given; it does no containment check. The check has to happen before you build it.
- os.path.join() and the pathlib / operator both drop the base when the second part is absolute: Path('/srv/files') / '/etc/passwd' is /etc/passwd.
- str.startswith() on resolved paths accepts /srv/files-secret, and os.path.commonprefix() compares characters, not path components. Use is_relative_to() or os.path.commonpath().
- name.replace('../', '') makes one pass, so ....// turns back into ../.
- Starlette's StaticFiles refuses paths that start with / or \ and, with the default follow_symlink=False, compares realpath() results with commonpath(), so a link that leads out of the folder is refused. With follow_symlink=True it compares abspath() results instead and serves such a link.
- Starlette decodes the query string first: a value of %2e%2e%2f reaches your path operation as ../.
- On macOS, tempfile.gettempdir() lives under /var, a symlink to /private/var. Resolve the base as well as the target, or every legitimate file will be refused.
- Checking and then serving leaves a window in which someone who can write inside the base can swap in a symlink. Keep the served tree read-only for untrusted users.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs path-traversal/python-fastapi does this for you).
- Against your own endpoint, request ?path=..%2Fsecret.txt, an absolute path and a file reached through a symlink that points outside the base; each must answer 404 without the file's content.
- Do not count on static analysis here: no Semgrep rule fired, even on a single-file probe, and CodeQL's model does not recognise resolve() plus is_relative_to(). The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/python, p/default, p/security-audit, p/owasp-top-ten | No path findings on any implementation (p/default's one finding is the deliberate importlib.import_module in app.py). A single-file probe with FileResponse(Path(BASE) / path) and open(BASE + "/" + path) on a FastAPI query parameter also gave no findings: the registry's path rules are written for Flask and Django request objects.Checked: Semgrep 1.180.0 (CE); on app.py and impl |
| CodeQL | py/path-injection | FastApi.qll models FileResponse's path argument as file-system access and the path query parameter as a remote source. As for Flask, the model treats a path as checked after os.path.normpath, abspath or realpath followed by str.startswith, not after resolve() and is_relative_to(), so the fixed code would probably still be reported if CodeQL followed the call; the implementation is loaded with importlib.import_module, which it does not resolve.Checked: github/codeql main at b5b8165; not executed |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No findings. Bandit has no path traversal plugin.Checked: Bandit 1.9.4; bandit -r app.py impl |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | FastAPI 0.142.2 (Starlette 1.7.0 FileResponse) |
| fastapi | 0.142.2 |
| starlette (FileResponse) | 1.7.0 |
| pathlib (standard library) | Python 3.14.8 |
| httpx2 (TestClient transport, test only) | 2.13.1 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the path-traversal/python-fastapifolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs path-traversal/python-fastapi runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python CPython 3.14.8
- fastapi 0.142.2
- starlette 1.7.0
- pytest 9.1.1
- semgrep 1.180.0
- bandit 1.9.4
- Matrix
- fixed: 12 of 12 tests pass; vulnerable: 5 of 12 tests fail (5 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
- Test results
- fixed 12/12 pass; vulnerable 5/12 fail (5 exploit tests: ../, %2e%2e%2f, the absolute path, the files-secret sibling, the symlink); wrong 3/12 fail (3 exploit tests: the absolute path, the symlink, ....//), offline after install. The ..\ test passes in every mode on macOS, where a backslash is an ordinary file-name character; it guards a Windows deployment.
- Dependency freshness
- FastAPI 0.142.2 (2026-09-30) and Pydantic 2.13.5 (2026-08-28) rather than 0.143.0 and 2.14.0 (2026-10-08), which fix no security issue; Starlette 1.7.0 (2026-09-23); httpx2 2.13.1 (2026-09-23); iniconfig 2.3.0 because 2.3.1 is from 2026-10-06. OSV reported no known vulnerabilities for any pinned package.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP: Path Traversal
- Python: pathlib.Path.resolve()
- Python: pathlib.PurePath.is_relative_to()
- Python: os.path.join() (absolute components)
- Starlette: FileResponse
- Starlette: StaticFiles (follow_symlink)
- FastAPI: static files
- PortSwigger Web Security Academy: Path traversal
Attack patterns: