Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix path traversal in Python and FastAPI

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework FastAPI 0.142.2 (Starlette 1.7.0 FileResponse)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 17 KB
On this page

Steps

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Reject an empty path and a NUL byte with 400 before touching the file system: pathlib raises ValueError on a NUL byte.
  6. For a folder of public files, mount StaticFiles(directory=...) instead of writing the route, and leave follow_symlink at its default, False.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.pyPython
def user_file(base_dir: str, requested_path: str) -> Path | None:
    return Path(base_dir) / requested_path

With IMPL=vulnerable, 5 of 12 tests fail (5 exploit tests).

The fix

Fixedimpl/fixed.pyPython
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 target

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
def user_file(base_dir: str, requested_path: str) -> Path | None:
    cleaned = requested_path.replace("../", "")
    return Path(base_dir) / cleaned

With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).

The regression test

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.

Testtests/test_files.pyPython
# 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"))

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix path traversal in Python and FastAPI
ToolRuleWhat it found here
Semgrepp/python, p/default, p/security-audit, p/owasp-top-tenNo 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
CodeQLpy/path-injectionFastApi.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
Banditall plugins (default profile)Bandit 1.9.4No findings. Bandit has no path traversal plugin.Checked: Bandit 1.9.4; bandit -r app.py impl

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFastAPI 0.142.2 (Starlette 1.7.0 FileResponse)
fastapi0.142.2
starlette (FileResponse)1.7.0
pathlib (standard library)Python 3.14.8
httpx2 (TestClient transport, test only)2.13.1
pytest9.1.1

Run the tests

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.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 17 KB

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

  1. OWASP: Path Traversal
  2. Python: pathlib.Path.resolve()
  3. Python: pathlib.PurePath.is_relative_to()
  4. Python: os.path.join() (absolute components)
  5. Starlette: FileResponse
  6. Starlette: StaticFiles (follow_symlink)
  7. FastAPI: static files
  8. PortSwigger Web Security Academy: Path traversal

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.