Path Traversal: How ../../settings.py Escapes Your Media Directory — and How Django's safe_join Stops It
Django Security Series — Post 9 | Series II: Broken Access Control
OWASP A01:2025 — Broken Access Control | Reading time: ~15 min
🧪 Run it yourself. This attack ships as a runnable lab in django-security-lab: a "download a document" endpoint that opens the file named in
?file=. Theos.path.joinview lets?file=../flag.txtclimb out of the document root and read a flag it should never reach; thesafe_joinview raisesSuspiciousFileOperationand returns 404. Reproduce both from the command line withcurl— and see the detection twist: the standard scanners give a false green here, so the lab ships the custom rule that actually catches realistic code.
Posts 6 through 8 dealt with access control failures over database objects. The attacker swapped an ID to read someone else's petition (Post 6), injected is_staff to promote themselves (Post 7), or tricked the victim's browser into submitting a forged request (Post 8). In each case the "resource" under attack was a model instance, a row in PostgreSQL. Post 9 moves the same failure to the filesystem.
While researching this post I ran os.path.join('/app/media', '/etc/passwd') in a Python shell and got back /etc/passwd. The base directory just disappeared. I had always assumed the first argument anchored the result. It does not — if any subsequent component is an absolute path, os.path.join silently discards everything before it. That one shell session changed how I think about every file-serving view I have ever written. It is the kind of latent bug that hides in ordinary production code — a hardcoded filename fed to os.path.join today, one refactor away from danger the moment that filename starts coming from a URL parameter. Running a real petition platform is what makes that failure mode feel concrete rather than academic.
Path traversal sits under A01:2025 (Broken Access Control) because the server hands out a file the user was never authorized to read: the "object" is a file on disk rather than a database row, retrieved without checking that it falls inside the allowed boundary. That is what this post is about: the gap between the directory a download view means to serve and the paths it will actually open.
The Attack: What It Is and How It Works
Path traversal exploits the way operating systems resolve relative path segments. When a path contains ../, the filesystem moves one directory upward. If a web application builds a file path by joining a base directory with a user-supplied filename, and the user supplies ../../../etc/passwd instead of report.pdf, the resulting path resolves outside the intended directory. The application opens a file it was never meant to serve.
It is the same root cause as every injection in this series: input the application treats as data acts as an instruction, here a filesystem one. os.path.join does not care. It is a string joiner: os.path.join('/app/media', '../../etc/passwd') returns '/app/media/../../etc/passwd' with the dot-dots still in the string, and the kernel resolves them later, when open() walks the path. The payload comes in two forms. Relative traversal uses ../ sequences to climb out. Absolute path injection is worse: if any component starts with / (or, on Windows, a drive letter such as C:\), os.path.join discards everything before it, so os.path.join('/app/media', '/etc/passwd') is '/etc/passwd'. Python's documentation says so explicitly; it still surprises developers who assumed the base would always be prepended.
The typical target is a download or file-serving endpoint that takes a filename from the URL or a query parameter: /download/?file=report.pdf or /media/serve/<filename>. The attacker swaps in ?file=../../../manage.py, ?file=../../../../etc/passwd or ?file=../settings.py, and reads whatever the web server process can.
The high-value targets on a Django server are predictable. settings.py contains SECRET_KEY, database credentials, AWS keys, and API tokens. Reading SECRET_KEY lets the attacker forge anything django.core.signing signs with that key (signed cookies, timestamped tokens), forge password-reset links if they also know the user's password hash and last-login time, forge a logged-in session for any user whose password hash they also hold if the project uses the signed_cookies session backend, and tamper with the cookie-based messages framework. /etc/passwd confirms usernames. .env files hold credentials that python-decouple or django-environ read at startup. On Linux servers, /proc/self/environ (the process's environment, which often carries secrets) and /proc/self/cmdline (the startup command) turn the filesystem into an information-gathering surface.
Path traversal reaches files by other routes too. A template name taken from the request can climb the same way, although Django's stock template loaders refuse any name that resolves outside their directories. A symlink inside the served folder can point outside it, which a check on the path's text does not notice. And an uploaded file's name is a path the attacker chooses, which Django has checked since the fix for CVE-2021-31542.
Real-World Incidents
Fortinet FortiOS SSL VPN — CVE-2018-13379 (2018–2020)
In May 2019, Fortinet published advisory FG-IR-18-384 for CVE-2018-13379, a path traversal vulnerability in the FortiOS SSL VPN web portal (affected versions: FortiOS 6.0.0–6.0.4, 5.6.3–5.6.7, 5.4.6–5.4.12; fixed in 6.0.5, 5.6.8, 5.4.13). Devcore researchers Meh Chang and Orange Tsai published technical details and presented the exploit at Black Hat USA 2019 in August. The flaw was in the SSL VPN portal's language-file handler (/remote/fgt_lang?lang=…): a specially crafted HTTP request allowed an unauthenticated attacker to read arbitrary files from the appliance, including sslvpn_websession, which contained plaintext usernames and passwords of active VPN users.
Despite Fortinet releasing patches, exploitation was widespread. In November 2020, two separate actors published exploitation data on hacking forums: one (“pumpedkicks”) posted 49,577 vulnerable FortiGate IP addresses with exploit one-liners on November 19, and another (“arendee2018”) posted extracted sslvpn_websession credential dumps around November 25. CISA added CVE-2018-13379 to its Known Exploited Vulnerabilities catalog. According to joint advisory AA21-321A (FBI, CISA, ACSC, NCSC, November 2021), Iranian state-sponsored actors scanned for the vulnerability and, the advisory assessed, likely exploited it to gain initial access to government and critical-infrastructure networks. The vulnerability scored 9.8 CRITICAL on CVSSv3 (NVD) — unauthenticated, network-exploitable, no user interaction required. One unvalidated parameter in an HTTP request, a ../ traversal padded until the handler's fixed-size buffer cut off the .json it appended, exposed the VPN credentials of tens of thousands of devices. In the UK, the NCSC told organisations running unpatched Fortinet VPN devices to assume compromise, remove the devices from service, and initiate incident response.
In ATT&CK terms the traversal itself is T1190 — Exploit Public-Facing Application; once the appliance was reading arbitrary files, the attack chained into T1083 — File and Directory Discovery (enumerating the filesystem), T1005 — Data from Local System (the read), and T1552.001 — Credentials In Files — which is exactly what sslvpn_websession was: a file full of plaintext credentials, the same class of harvest a Django traversal performs when it reads settings.py or a .env.
Source: NVD — CVE-2018-13379: Fortinet FortiOS Path Traversal (CVSS 9.8)
Django's Default Protections
Ask "does Django protect me from path traversal?" and the honest answer is: it depends on how you serve files.
If retrieval goes through a model's FileField or ImageField (which it should), the filename is never a raw path from the user. FileField.upload_to controls the directory, the storage backend controls where the file lands, and retrieval goes through FieldFile.open() with the name the model stored. The user never supplies a filesystem path. They interact with a model instance, and the model holds the storage key. This is the IDOR pattern from Post 6 applied to files: the "identifier" is a database primary key or UUID, not a filename, and the queryset is scoped to authorized objects. Path traversal on retrieval cannot happen because no user-supplied path reaches the filesystem. Hand a name from the request straight to default_storage.open() and that design is gone: FileSystemStorage still contains the name with safe_join, but a custom backend need not.
The danger surfaces when a developer steps outside the storage abstraction: a custom download endpoint that takes a filename from the URL, a view that builds a path with os.path.join(MEDIA_ROOT, request.GET['file']), or a file-serving view that opens a path the user controls. That is where Django's one dedicated utility matters.
django.utils._os.safe_join does what os.path.join does not: after joining the base and the user-supplied component, it resolves the result to an absolute path and checks that it still starts with the base. If it does not, if ../ sequences or an absolute path escaped the sandbox, it raises SuspiciousFileOperation, a subclass of SuspiciousOperation that Django's default error handling turns into a 400. FileSystemStorage (and so StaticFilesStorage and collectstatic), the development-only django.views.static.serve, and the filesystem template loaders all call it. The check is simple and effective, with one caveat: safe_join lives in django.utils._os, a private module (leading underscore). There is no public API reference entry, no deprecation cycle, and no backwards-compatibility guarantee. Django's most useful path-safety primitive is one it will not promise to keep.
What Django does NOT protect automatically:
- Any custom view that builds a file path from user input using
os.path.join,pathlib.Path, or string concatenation. - Any view that passes a user-supplied name to
open(),FileResponse(), orPath.read_bytes()without path validation. - Management commands or Celery tasks that process user-supplied filenames from the database.
The Vulnerable Pattern and the Fix
Pattern 1 — os.path.join with a Filename from the Request
The lab's download view is the textbook case: it opens whatever ?file= names under a document root. The fix replaces the join:
name = request.GET.get("file", "")
...
- path = os.path.join(str(DOCS_ROOT), name)
+ try:
+ path = safe_join(str(DOCS_ROOT), name)
+ except SuspiciousFileOperation:
+ raise Http404()
try:
data = open(path, "rb").read()
except OSError:
raise Http404()
return HttpResponse(data, content_type="text/plain")
On the vulnerable view, ?file=../flag.txt climbs from documents/ to its parent and returns the flag, and the absolute path of the flag file works too, because os.path.join drops the base. On the secure view both payloads raise SuspiciousFileOperation and answer 404, while ?file=readme.txt is still served. Both views are in the lab: views_vulnerable.py and views_secure.py. In a project the base is MEDIA_ROOT: with MEDIA_ROOT = BASE_DIR / 'media', ?file=../../projectname/settings.py against a documents/ subfolder climbs to BASE_DIR and reads the settings module, and an os.path.exists check in front of open() changes nothing, because the file exists.
Catch SuspiciousFileOperation and answer 404 rather than letting it through: uncaught, Django answers a generic 400, which tells the attacker the check fired, while a 404 is indistinguishable from "the file does not exist". Catching it has a cost an analyst should know: Django logs an uncaught SuspiciousFileOperation, resolved path included, to the django.security.SuspiciousFileOperation logger, and the except clause skips that entry, so log the attempt yourself before raising Http404.
Pattern 2 — A Filename in the URL Instead of a Record
The strongest fix removes the filename from the request entirely:
-def serve_report(request, filename):
- path = os.path.join(settings.MEDIA_ROOT, filename)
- return FileResponse(open(path, 'rb'))
+@login_required
+def download_document(request, document_id):
+ doc = get_object_or_404(Document, pk=document_id, owner=request.user)
+ return FileResponse(doc.file.open('rb'), as_attachment=True,
+ filename=os.path.basename(doc.file.name))
The user supplies an identifier that maps to a model instance, and the model's FileField resolves the storage path internally, so there is no path to traverse. It also inherits the IDOR protection from Post 6: the queryset is scoped to owner=request.user, so the authorization check and the path-safety guarantee come from the same line. This is the pattern Petição Brasil uses for signature PDFs: the user provides a UUID, the view looks up the Signature, checks that the requester is the petition's creator, the signer or staff, and asks the field's private storage for a presigned S3 URL (signature.signed_pdf.url). No user-supplied string ever touches a filesystem path.
When the endpoint genuinely needs to accept a name (one of a known set of reports, say), map a key to a server-defined path, ALLOWED_REPORTS = {'q1-2026': 'reports/q1_2026_summary.pdf'}, so ../../settings.py matches no entry and answers 404. Where even that is impractical, require a strict pattern such as \A[A-Za-z0-9_-]+\.[A-Za-z0-9]+\Z, which admits no /, \, .. or null byte, and still build the path with safe_join: the regex catches the obvious payloads, and safe_join catches anything it missed.
The Analyst's View
For a CySA+ analyst, path traversal is a useful reminder that a clean scan is not the same as a safe application. CWE-22 is fundamentally a canonicalization failure — the server decides which file to serve before it resolves what the path actually points to — and canonicalization bugs are notoriously hard for pattern-based SAST to see, because the defect is the absence of a resolve-and-contain step rather than a dangerous call you can match. As the detection section below shows, the standard tools return nothing on the realistic form here, and one of them returns a false green that is worse than silence. An analyst who reads "0 findings" as "no path traversal" has misread the tool.
That shifts the weight of detection onto human judgement and dynamic testing: a targeted code review of every file-serving view (grep for os.path.join, open(), and FileResponse fed from the request), and a ../ probe against the running endpoint. This is the recurring lesson of the access-control series — authorization and containment are properties a scanner can rarely confirm, so they belong on the threat-model checklist, not only in the CI pipeline.
Catching It Automatically
Testing Your Defence
One test proves the fix against this payload: a ../ request to the secure view must answer 404 without the flag. This is the one the lab uses:
def test_secure_view_blocks_relative_traversal(self):
resp = self.client.get("/path-traversal/secure/", {"file": RELATIVE_PAYLOAD})
# safe_join raised SuspiciousFileOperation, turned into a generic 404.
self.assertEqual(resp.status_code, 404)
self.assertNotContains(resp, "FLAG{", status_code=404)
RELATIVE_PAYLOAD is ../flag.txt, and setUp plants documents/readme.txt plus the flag file one level above it. Point the test at /path-traversal/vulnerable/ and it fails: the response is 200 and carries the flag. The lab's tests.py adds the absolute-path payload against both views and the half that keeps the fix honest: the secure view still serves readme.txt, without which a view that rejects everything would pass every rejection check. Percent-encoded payloads need a raw query string in the test (?file=..%2f..%2fsettings.py): passing '..%2f..' in the data dict re-encodes the % and the view never sees a traversal.
Scanning It
I expected this to be an easy scan. Path traversal via os.path.join is a textbook bug, so I pointed the usual tools at the lab's two views assuming both would light up the vulnerable one. Instead I got a clean bill of health on code I know leaks the flag — and chasing down why turned into the most useful thing in this post.
Bandit (a quick reminder: it walks the Python AST looking for B-numbered risky calls — shell=True, eval, yaml.load) reports zero findings on both views. That is not a tuning problem: Bandit's one path-traversal check, B202, covers tarfile.extractall, not a path built from a request, and it does no dataflow, so it has no way to connect request.GET → os.path.join → open. On this form Bandit is simply blind.
Semgrep community (structural pattern matching; its OSS engine follows taint only within a function) reports zero as well — from p/django, p/python, and p/owasp-top-ten together. That surprised me more, because Semgrep does ship a Django path-traversal rule: python.django.security.injection.path-traversal.path-traversal-join. Two things keep it from helping, and both are worth an analyst's attention:
- It is not in the curated packs. Its metadata marks it
subcategory: audit,confidence: LOW, and none of the three packs includes it, like the@csrf_exemptandmark_saferules earlier in this series. You have to ask for it by name. - Even asked for by name, it misses realistic code. It is not a taint rule but a list of fixed shapes, and its only two-hop shape reads the filename as
request.$W.get($X), a call with one argument. The lab, like most download views, reads it with a default,request.GET.get("file", ""), so the shape never matches.
Reading the filename with a default, building the path into another variable, then opening it is an ordinary way to write a download view, and the rule stays silent on it. Drop the default, request.GET.get("file"), and the same two-hop chain fires; add a third step and nothing matches. So the off-the-shelf rule gives a false green: it fires on one spelling of the chain and stays quiet on the lab's. This section used to explain the miss differently, as an engine limit: the OSS engine "only follows one variable indirection", and bridging two hops needs Semgrep's Pro engine. I had that wrong. The probes that separate the two explanations, the read with and without a default, are captured in the lab's scans/semgrep.txt.
A false green is worse than no signal, because it invites you to trust it. So the lab ships a small custom Semgrep rule, rules/path_traversal.yaml, written in taint mode, which the free engine runs within a function: anything read from request, or a URL parameter in the view's signature, is a source; a path handed to open(), or one whose .open(), .read_bytes() or .read_text() is called, is a sink; safe_join(), a record lookup and an is_relative_to() check on a resolved path that raises are the sanitizers. It fires on the vulnerable view, on longer chains and on the pathlib reads, and stays silent on the safe_join fix, Pattern 2's record lookup and a join of hardcoded names. Its limits are documented in its fixture: a name the view has validated itself in any other way, with an allowlist regex say, still fires, because Semgrep cannot read the check; and taint does not cross functions, so a name read in one function and opened in another is missed.
grep -rn "os.path.join" --include="*.py" . # then read every open()/FileResponse() fed from the request
# the standard tools — miss it
bandit -r labs/post_09_path_traversal/views_vulnerable.py labs/post_09_path_traversal/views_secure.py
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_09_path_traversal/views_vulnerable.py
# the custom taint rule — catches it
semgrep scan --config rules/path_traversal.yaml labs/post_09_path_traversal/views_vulnerable.py # 1 finding
semgrep scan --config rules/path_traversal.yaml labs/post_09_path_traversal/views_secure.py # 0 findings
# and the dynamic half, against the running lab
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8000/path-traversal/secure/?file=../flag.txt" # 404; /vulnerable/ answers 200
The practical takeaway for a code review: grep is not beneath you. The grep above, plus a look at every open() / FileResponse() that takes a name from the request, finds this class faster than a SAST run that reports nothing, and a ../ payload against a live endpoint proves the fix. A hermetic CI job asserts the custom rule's fire/silent pair on every push.
The habit this post left me with: a name from a request never reaches open() except through safe_join, and where I can, the request carries a record's ID instead of a filename.
Post 10 closes Series II with Mass Assignment, where the attacker writes fields the developer never exposed, turning a profile-edit endpoint into a database-wide write surface.
Further Reading
- django-security-lab — Path Traversal lab (runnable vulnerable/secure views, captured scans, and the custom Semgrep rule)
- Django Docs — File Storage API
- Django Docs — FileField Reference
- Django Source (GitHub) — django.utils._os.safe_join
- OWASP A01:2025 — Broken Access Control
- OWASP Cheat Sheet — Input Validation
- PortSwigger Web Security Academy — Path Traversal
- MITRE ATT&CK — T1190: Exploit Public-Facing Application
- MITRE ATT&CK — T1083: File and Directory Discovery
- NVD — CVE-2018-13379: Fortinet FortiOS Path Traversal (CVSS 9.8)
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 11: Permissions