Vulnerability class

Path traversal in Python

Path traversal is a flaw where data an attacker controls decides which file the program opens, letting them escape the directory the program meant to stay in and read or write files elsewhere.

CWE
CWE-22
OWASP
A01:2025 Broken Access Control
Tag
path traversal
On this page

How it shows up in Python

The usual shape joins a base directory to a name from the request and opens it:

import os
from flask import request

DOCS = "/srv/app/user-docs"

@app.get("/download")
def download():
    name = request.args["file"]
    with open(os.path.join(DOCS, name), "rb") as f:
        return f.read()

os.path.join looks protective but is not: a file that climbs out of the directory with .. segments resolves to a path outside DOCS, and an absolute path passed as name discards the base entirely, because os.path.join("/srv/app", "/etc/passwd") is /etc/passwd. The attacker reads files the web user can read.

The fix is to resolve the final path and confirm it is still inside the base directory, rather than trying to filter the input:

from pathlib import Path
from flask import request, abort

DOCS = Path("/srv/app/user-docs").resolve()

@app.get("/download")
def download():
    name = request.args["file"]
    target = (DOCS / name).resolve()
    if not target.is_relative_to(DOCS) or not target.is_file():
        abort(404)
    return target.read_bytes()

resolve() collapses .. and symlinks, and is_relative_to() (Python 3.9+) rejects anything that landed outside. When the set of files is known, the sturdier option is to not take a path at all: accept an identifier and look the real path up in a dictionary or a database row. The same traversal applies to writes (open(path, "w"), os.makedirs), and writing outside the directory is usually worse than reading.

Archive extraction is path traversal with the attacker supplying the names. A crafted entry name in a zip or tar can carry .. segments or an absolute path, so extracting it writes outside the target directory. On Python 3.12+ pass filter="data" to TarFile.extractall; otherwise validate each resolved member path against the destination before writing it, the same check as above.

Why it is hard to see

  • Second-order. The name is stored in one request and used to build a path in another, or in a later job. No single handler shows input flowing to open().
  • Decoded or rebuilt in the app. The traversal is URL-encoded, or assembled from two fields, or pulled out of a parsed document, so there is no .. in any single request field for a signature to catch.
  • No response signal. A write that traverses, or a read whose result is emailed rather than returned, gives a black-box scanner no difference in the reply to detect.
  • Archive entries. The malicious name is inside an uploaded zip or tar, not in the request body at all.

What the es-python event looks like

es-python reports when an untrusted path reaches a file operation. Two things fire together. The base event names the operation: io open for open(), and os stat, os open, os listdir, os mkdir, os unlink and their siblings for the os calls. Alongside it, a path traversal event fires when the untrusted path contains a .. segment or is absolute, and that is the one tagged path traversal. A null byte injection event marks an embedded NUL, which is always deliberate. Archive extraction has its own labels, zip slip and tar slip, checked against the raw member name before any sanitising runs.

A plain io open on untrusted input, with no traversal in the value, still produces an event, because reading an attacker-named file in the intended directory can still be wrong; it carries the path traversal tag at a lower grade. The matching is byte-level and POSIX-only: it looks for / separators and does not decode percent-encoding, so a value the application URL-decodes itself is covered only after that decode happens in Python. The severity follows the source: network data reaching a path is graded high, and a path from a file only the owner can write is Info.

A write event is graded differently: it is a disclosure signal, so es-python records where the data was written and whether other users can read that file, and grades it from that. Under a Raise rule, the operation raises a RuntimeError before the file is opened.

How to fix and prevent it

  1. Resolve the final path with Path.resolve() and confirm it is inside the intended directory with is_relative_to(). Skip stripping .. by hand.
  2. Prefer an identifier mapped to a known path over accepting a path at all.
  3. Reject absolute paths and embedded NUL bytes outright.
  4. Extract archives with filter="data" on 3.12+, or validate each member's resolved path on older versions.
  5. Run the application as a user that cannot read or write anything outside what it needs, so a traversal that slips through reaches less.

Classification

Path traversal is CWE-22, Improper Limitation of a Pathname to a Restricted Directory. OWASP maps CWE-22 to A01:2025 Broken Access Control in the Top 10 for 2025.

Questions people ask

The questions that come up on forums and in reviews about this class, each answered in a few sentences.

Does os.path.join prevent path traversal?

No: os.path.join() joins text, so a component that is absolute replaces everything before it, and a .. segment is kept and walks out of the directory when the path is opened. Resolve the joined path with os.path.realpath() and check that the result still starts with the intended base directory, or map user choices to file names you wrote. es-python reports an untrusted path that contains .. or is absolute as a path traversal event.

How do I safely build a file path from user input in Python?

Treat the user's value as a key, not a path: look it up in a dictionary of allowed names, or restrict it to a single safe component with no separators and no ... If the value must be a path, resolve it with pathlib.Path.resolve() and refuse it unless it is inside the base directory after resolution. Keep the check on the resolved path, because a check on the raw string is what traversal bypasses.

How does es-python report path traversal?

It reports at the file operation, when an untrusted path reaches open() or an os call, with the value, its source and the stack trace. A path traversal event fires alongside when the path contains a .. segment or is absolute, zip slip and tar slip mark archive members that escape, and null byte injection marks an embedded NUL. Network data reaching a path is graded High, and a Raise rule stops the operation before the file is opened.

Watch for Path traversal in your own code

es-python runs your Python application unchanged and reports when untrusted data reaches the operation, with where the data came from and the line that used it. Book a live demo to see it on a real application.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo