Resolve both paths and compare whole components: base = Path(settings.FILES_ROOT).resolve(), target = (base / name).resolve(), and return FileResponse(target.open('rb')) only if target.is_relative_to(base). resolve() collapses ../ and follows symlinks; the check also catches absolute names. Do not rely on safe_join(), stripping "../" or startswith.
- Find every open(), Path.open() or read_bytes(), FileResponse, default_storage.open() and os.path.join() whose path includes request data (request.GET, request.POST, URL kwargs 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), 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.
- Serve with FileResponse(target.open('rb'), content_type=...) and an explicit content type, or as_attachment=True for downloads.
- 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, store them as FileField values or 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 resolve_user_file(base_dir, requested_path: str) -> Path | None:
return Path(base_dir) / requested_pathWith IMPL=vulnerable, 5 of 12 tests fail (5 exploit tests).
The fix
def resolve_user_file(base_dir, 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.
return target if target.is_relative_to(base) else NoneWith IMPL=fixed, 12 of 12 tests pass.
A fix that looks safe but is not
def resolve_user_file(base_dir, requested_path: str) -> Path | None:
try:
return Path(safe_join(base_dir, requested_path))
except SuspiciousFileOperation: # ../ or an absolute path outside base_dir
return NoneWith IMPL=wrong, 1 of 12 tests fail (1 exploit 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.
# 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_django_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"))- django.utils._os.safe_join() refuses ../ and absolute paths but compares abspath() strings, so it does not follow symlinks: a link inside the folder that points outside it was served on Django 6.1.2. That is this card's wrong implementation. It is also a private module, and FileSystemStorage.path() and django.views.static.serve use the same function.
- django.views.static.serve is for development only; Django's documentation says it is not hardened for production use.
- 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; use is_relative_to() or os.path.commonpath().
- Django decodes the query string before your view sees it: ?path=%2e%2e%2fsecret.txt arrives as ../secret.txt.
- SuspiciousFileOperation, which safe_join and storage backends raise, is a SuspiciousOperation: uncaught, Django answers 400 and logs it to django.security, which tells a prober the path was refused rather than missing.
- On macOS, temporary folders live 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 opening 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-django 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: Semgrep's Django rule only knows os.path.join() in the view, CodeQL's model recognises realpath plus startswith but not resolve() plus is_relative_to(), and nothing flags safe_join's symlink gap. 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.django.security.injection.path-traversal.path-traversal-join.path-traversal-join | No findings on this card: it matches os.path.join() with request data inside one view, and this card reads request.GET in views.py and builds the path with pathlib in files_app/impl/. On a probe view it flagged open(os.path.join(BASE, request.GET.get('path'))) but not (Path(BASE) / request.GET.get('path')).open('rb').Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> files_app config (Ran 1 rule) |
| Semgrep | python.django.security.injection.path-traversal.path-traversal-open.path-traversal-open, python.django.security.injection.path-traversal.path-traversal-file-name.path-traversal-file-name | No findings on the card or on the probe view.Checked: Semgrep 1.180.0 (CE) (Ran 1 rule each); also p/django, p/python, p/security-audit and p/owasp-top-ten |
| 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 and Path.open() is file-system access, but is_relative_to() is not modelled as a check, so this card's fixed code would probably still be reported. Django's safe_join is not modelled as a sanitizer either.Checked: github/codeql main at b5b8165; not executed |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No findings: Bandit has no path traversal check.Checked: Bandit 1.9.4; bandit -r files_app config |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Django 6.1.2 (FileResponse) |
| django | 6.1.2 |
| pathlib (standard library) | Python 3.14.8 |
| pytest | 9.1.1 |
| pytest-django | 4.14.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the path-traversal/python-djangofolder.
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-django 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
- django 6.1.2
- pytest 9.1.1
- pytest-django 4.14.0
- 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: 1 of 12 tests fail (1 exploit test)
- Test results
- fixed 12/12 pass; vulnerable 5/12 fail (5 exploit tests: ../, %2e%2e%2f, the absolute path, the files-secret sibling and the symlink); wrong 1/12 fail (the symlink exploit test), 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, and the four-dot test passes because nothing here strips ../.
- Dependency freshness
- Django 6.1.2 was published on 2026-10-06 and is taken at four days old because it is a security release (CVE-2026-77050, CVE-2026-84429, CVE-2026-87890); pytest-django 4.14.0 dates from 2026-08-10. iniconfig is pinned at 2.3.0 rather than 2.3.1 (2026-10-06), which was four days old and is not a security release. OSV reported no known vulnerabilities for any pin. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
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)
- Django 6.1: FileResponse objects
- Django 6.1: static.serve (not hardened for production)
- Django 6.1: SuspiciousOperation and the django.security logger
- Django source: django/utils/_os.py (safe_join)
- PortSwigger Web Security Academy: Path traversal
Attack patterns: