Path traversal in Python file handling: os.path.join, pathlib, send_file and zip extraction

Path traversal in Python comes from building a filesystem path out of a client-supplied name and trusting the result. os.path.join and pathlib both discard the base directory when the name is absolute, neither removes .. segments, and archive members bring their own names with them. The fix is the same everywhere: resolve the final path, confirm it is still inside the directory you meant, and only then open it. This article goes through the standard library joins, the Flask and Django file responses, upload names and zip and tar extraction, with code for each.

The weakness is CWE-22, and the 2025 OWASP Top 10 lists it under A01 Broken Access Control.

os.path.join is a join, not a guard

The Python documentation describes the behaviour exactly: "If a segment is an absolute path (which on Windows requires both a drive and a root), then all previous segments are ignored and joining continues from the absolute path segment" (os.path.join). The documentation's own example is os.path.join('/home/foo', '/home/bar'), which returns '/home/bar'.

So this handler has two separate problems:

import os

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


def read_document(name: str) -> bytes:
    with open(os.path.join(DOCS, name), "rb") as handle:
        return handle.read()

A name of /etc/hostname replaces the base entirely. A name of ../../../etc/hostname keeps the base and climbs out of it, because join concatenates and nothing collapses the .. segments until the operating system walks the path.

pathlib reads more modern and behaves the same way. Path("/srv/app/user-docs") / "/etc/hostname" is /etc/hostname, and .. segments are kept as written.

Checks that look right and are not

Rejecting .. in the string. It does not cover the absolute-path case, and it runs before any decoding the application does later. A name that is percent-decoded, base64-decoded or pulled out of a JSON document after the check carries its traversal past it.

os.path.normpath alone. It collapses .. textually, so normpath("/srv/app/user-docs/../../etc") is /srv/etc. It tells you where the path points; it does not stop it pointing there. It also knows nothing about symbolic links.

A startswith test on strings. This one survives code review regularly:

import os

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


def is_inside_wrong(path: str) -> bool:
    return os.path.realpath(path).startswith(DOCS)

/srv/app/user-docs-private/secret.txt starts with /srv/app/user-docs. A string prefix is not a directory boundary.

The check that works

Resolve first, then compare paths as paths:

from pathlib import Path

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


class OutsideBaseDirectory(Exception):
    pass


def safe_path(name: str) -> Path:
    target = (DOCS / name).resolve()
    if not target.is_relative_to(DOCS):
        raise OutsideBaseDirectory(name)
    return target


def read_document(name: str) -> bytes:
    target = safe_path(name)
    if not target.is_file():
        raise FileNotFoundError(name)
    return target.read_bytes()

Path.resolve() makes the path absolute, collapses .. and follows symbolic links. is_relative_to(), available since Python 3.9, compares whole path components, so the user-docs-private case is rejected. With os.path, the equivalent is os.path.commonpath([base, os.path.realpath(candidate)]) == base.

Two limits remain. If another user can create symbolic links inside the directory between your check and your open, the check can be raced; keep upload directories writable only by the service. And when the set of files is known, the sturdier design is to take no path at all: accept an identifier, look the real path up in a database row, and open that.

Flask: send_file and send_from_directory

Flask's API reference is direct about send_file: "Never pass file paths provided by a user. The path is assumed to be trusted, so a user could craft a path to access a file you didn't intend. Use send_from_directory() to safely serve user-requested paths from within a directory" (flask.send_file).

from flask import Flask, send_from_directory

app = Flask(__name__)
app.config["UPLOAD_FOLDER"] = "/srv/app/uploads"


@app.get("/uploads/<path:name>")
def download(name: str):
    return send_from_directory(app.config["UPLOAD_FOLDER"], name, as_attachment=True)

send_from_directory joins with Werkzeug's safe_join and answers 404 when the result would leave the directory. Note the argument order: the directory is the trusted argument and the path is the client's. A call that builds the directory from request data (a tenant name, a language code) hands the trusted slot back to the client.

For names coming in, secure_filename reduces an upload's filename to a flat, ASCII name. It can return an empty string, so check for that, and prefer a name you generate (a UUID) with the original kept as metadata.

Django: FileResponse opens what you give it

FileResponse streams an open file object. It performs no path check, because by the time it is involved the file is already open:

from django.http import FileResponse, Http404

from .paths import OutsideBaseDirectory, safe_path


def download(request, name: str):
    try:
        target = safe_path(name)
    except OutsideBaseDirectory:
        raise Http404("No such document")
    if not target.is_file():
        raise Http404("No such document")
    return FileResponse(target.open("rb"), as_attachment=True)

Django's storage API and FileField validate names on the way in, which is one more reason to store uploads through them and serve by primary key, with the path read from the model.

Archives bring their own paths

Extraction is traversal with the attacker writing the names. A member called ../../home/app/.ssh/authorized_keys, or one with an absolute name, is written where the name says unless something stops it.

zipfile. ZipFile.extract() and extractall() sanitise member names: the documentation notes that a leading slash or drive is stripped and "all .. components in a member filename will be removed" (ZipFile.extract). The same page still warns, under extractall(), "Never extract archives from untrusted sources without prior inspection." The exposure usually comes from code that does not use those methods, because it wants to filter or rename members:

import os
import shutil
import zipfile


def unpack_wrong(archive: str, dest: str) -> None:
    with zipfile.ZipFile(archive) as bundle:
        for name in bundle.namelist():
            with bundle.open(name) as src, open(os.path.join(dest, name), "wb") as out:
                shutil.copyfileobj(src, out)

name goes straight into os.path.join, with both problems from the first section. Run every member through the same resolve-and-compare check:

import shutil
import zipfile
from pathlib import Path


def unpack(archive: str, dest: str) -> None:
    base = Path(dest).resolve()
    with zipfile.ZipFile(archive) as bundle:
        for info in bundle.infolist():
            target = (base / info.filename).resolve()
            if not target.is_relative_to(base):
                raise ValueError("member escapes destination: " + info.filename)
            if info.is_dir():
                target.mkdir(parents=True, exist_ok=True)
                continue
            target.parent.mkdir(parents=True, exist_ok=True)
            with bundle.open(info) as src, open(target, "wb") as out:
                shutil.copyfileobj(src, out)

tarfile. Tar archives can also contain symbolic links, hard links and device files, so the names are only part of the problem. Python 3.12 added extraction filters; the data filter rejects absolute paths, links that point outside the destination and special files. The documentation records the change of default: in Python 3.14 the default extraction filter became data; before that, in the documentation's words, "the filter strategy was equivalent to fully_trusted". On 3.12 and 3.13, pass it yourself:

import tarfile


def unpack_tar(archive: str, dest: str) -> None:
    with tarfile.open(archive) as bundle:
        bundle.extractall(dest, filter="data")

Neither module limits how much an archive expands to, so cap the total size and member count separately when the archive is untrusted.

Second-order paths

The hardest traversal to see in review is one where the name is stored first. A profile field, an "export name", a report template path is saved in one request with a parameterized INSERT, and joined into a path by a nightly job. Neither function looks wrong alone, and no request to the job carries a .. at all. Apply safe_path at the point of use, every time, regardless of how trustworthy the stored value feels.

What runtime evidence adds

Review and tests cover the paths you thought of. es-python, the EyalSec Python runtime, adds what the running program actually did: it follows untrusted data through the program as it runs and checks it at the operation itself, the file call. When an untrusted path reaches open() or an os call, the event names the operation (io open, os stat, os listdir, os unlink and their siblings), and a path traversal event fires alongside when the untrusted path contains a .. segment or is absolute. Archive extraction has its own labels, zip slip and tar slip, checked against the raw member name (what the event looks like). With the db_rows source on, a name read back from the database is followed too, which is how the second-order case appears.

Know the limits. The matching is byte-level and POSIX-only: it looks for / separators and does not decode percent-encoding, so a value your application URL-decodes itself is covered only after that decode happens in Python. And an event is evidence of a flow, not a verdict: a plain io open on an untrusted name inside the intended directory is reported at a lower grade, because it may be exactly what the feature does. The event shows the value, where it entered and the call path so you can decide. Under a Raise rule the operation is stopped before the file is opened. See How EyalSec works for the model and pricing for cost.

Checklist

  • Never open os.path.join(base, name) or base / name without resolving and comparing.
  • Compare paths with is_relative_to or commonpath, never with startswith.
  • In Flask, send_from_directory with a fixed directory; in Django, check before FileResponse.
  • Generate stored filenames; keep the client's name as data.
  • Extract zips with the library methods or the per-member check; extract tars with filter="data".
  • Re-check stored names at the point of use.

If you want to see which file operations on your own services receive untrusted paths, you can book a live demo.

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