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.
- 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).
- 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.
- 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.
- Reject an empty path and a NUL byte with 400 before touching the file system: pathlib raises ValueError on a NUL byte.
- 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 ....//.
- When users only pick from known files, map an ID to a server-side path instead of accepting a path at all.
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 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
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
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 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_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"))- 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.
- 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).
- 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: 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.
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 | python.flask.security.injection.path-traversal-open.path-traversal-open | No 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) |
| CodeQL | py/path-injection | From 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 |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| werkzeug | 3.1.9 |
| pathlib (standard library) | Python 3.14.8 |
| 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-flaskfolder.
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-flask runs all three modes and checks the expected results.
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
- OWASP: Path Traversal
- OWASP WSTG: Testing Directory Traversal File Include
- Python: pathlib.Path.resolve()
- Python: pathlib.PurePath.is_relative_to()
- Python: os.path.join() (absolute components)
- Python: os.path.commonpath() and commonprefix()
- Flask: send_from_directory()
- Werkzeug: safe_join()
- GHSA-g6x2-hccm-hh4m: Werkzeug safe_join() allows Windows special device names
- PortSwigger Web Security Academy: Path traversal
Attack patterns: