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

Fix path traversal in Python and Flask

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: base = Path(base_dir).resolve(), target = (base / name).resolve(), and serve 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 Flask 3.1.3
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 21 KB
On this page

Steps

  1. Find every open(), Path.read_bytes() or read_text(), send_file() and os.path.join() whose path includes request data (request.args, request.form, view arguments or uploaded file names).
  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. Serve the file only if target.is_relative_to(base) (Python 3.9 or later), and answer the same 404 for refused and missing paths so the response does not reveal what exists outside the folder.
  4. Reject an empty path and a NUL byte with 400 before touching the file system: pathlib raises ValueError on a NUL byte.
  5. Add regression tests with this card's payloads: ../, %2e%2e%2f, an absolute path, ..\, the files-secret sibling, a symlink created in tmp_path, and ....//.
  6. When users only pick from known files, map an ID to a server-side path instead of accepting a path at all.

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 read_user_file(base_dir: str, requested_path: str) -> bytes | None:
    return (Path(base_dir) / requested_path).read_bytes()

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

The fix

Fixedimpl/fixed.pyPython
def read_user_file(base_dir: str, requested_path: str) -> bytes | 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
    return target.read_bytes()

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

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

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_werkzeug_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

  • os.path.join() and the pathlib / operator both drop the base when the second part is absolute: os.path.join('/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().
  • os.path.abspath() and os.path.normpath() only rewrite the string; resolve() and os.path.realpath() also follow symlinks.
  • name.replace('../', '') makes one pass, so ....// turns back into ../.
  • Flask's send_from_directory() (Werkzeug's safe_join) refuses ../ and absolute names, but safe_join's own documentation says it does not check whether a path is a symlink, and a link inside the folder that points outside it was served (Flask 3.1.3, Werkzeug 3.1.9). Keep that folder free of links you did not create, or use the resolve() check.
  • Werkzeug decodes the query string first: a value of %2e%2e%2f reaches your view 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.
  • This card was not run on Windows. There, also refuse reserved device names such as CON and NUL, which hang when read; Werkzeug's safe_join has refused them since 3.1.4, with further fixes up to 3.1.9 (GHSA-g6x2-hccm-hh4m).
  • Checking and then reading 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-flask 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: the Semgrep rule above only knows open(), and CodeQL's model recognises os.path.realpath plus str.startswith but not 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 Flask
ToolRuleWhat it found here
Semgreppython.flask.security.injection.path-traversal-open.path-traversal-openNo findings on this card. The rule is a pattern match for open() calls that use flask.request data or a route argument inside the same view; the card reads request.args in app.py and uses pathlib in impl/*.py. On a probe view it flagged open(os.path.join(BASE, request.args.get("path"))) but not the same path read with (Path(BASE) / request.args.get("path")).read_bytes().Checked: Semgrep 1.180.0 (CE)
CodeQLpy/path-injectionFrom PathInjectionQuery.qll and frameworks/Stdlib.qll: the model treats a path as checked after a normalisation (os.path.normpath, abspath or realpath) followed by a str.startswith test. pathlib's / operator and resolve() carry taint, resolve() and read_bytes() are modelled as file-system access, and is_relative_to() is not modelled as a check, so this card's fixed code would probably still be reported. The str.startswith check needs no trailing separator, so the files-secret mistake would pass it.Checked: github/codeql main at b5b8165; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
werkzeug3.1.9
pathlib (standard library)Python 3.14.8
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-flaskfolder.

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-flask runs all three modes and checks the expected results.

Download the project ZIP, 21 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • flask 3.1.3
  • werkzeug 3.1.9
  • pytest 9.1.1
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
IMPL=fixed 12/12 pass; IMPL=vulnerable 5/12 fail, all exploit tests (../, %2e%2e%2f, the absolute path, the files-secret sibling, the symlink); IMPL=wrong 3/12 fail, all exploit tests (the absolute path, the symlink, ....//). The ..\ test passes in every mode on macOS and Linux, where a backslash is an ordinary file-name character; it guards a Windows deployment.
Dependency freshness
requirements.txt pins all 12 packages with sha256 hashes for every file PyPI lists, so pip installs in hash-checking mode. werkzeug 3.1.9 (2026-09-27) is 13 days old and taken because it is a security release (GHSA-g6x2-hccm-hh4m, safe_join on Windows). markupsafe 3.0.3 and iniconfig 2.3.0 are pinned instead of 3.0.4 (2026-10-02) and 2.3.1 (2026-10-06), which are not security releases.
Scanners
Semgrep 1.180.0 from a throwaway venv (pip install semgrep==1.180.0), not a card dependency; CodeQL read from source, not executed.

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. OWASP WSTG: Testing Directory Traversal File Include
  3. Python: pathlib.Path.resolve()
  4. Python: pathlib.PurePath.is_relative_to()
  5. Python: os.path.join() (absolute components)
  6. Python: os.path.commonpath() and commonprefix()
  7. Flask: send_from_directory()
  8. Werkzeug: safe_join()
  9. GHSA-g6x2-hccm-hh4m: Werkzeug safe_join() allows Windows special device names
  10. 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.