Ch. 9 · Python

Python Pathlib and Safe File Selection

Joining a user-supplied name onto a base Path can escape it via .., absolute paths or symlinks. Contain it with resolve() and is_relative_to.

~5 min readadvancedupdated Oct 7, 2026

pathlib makes path handling pleasant: BASE / "reports" / name reads like a sentence and works on every OS. That pleasantness is also a trap when name comes from a user, a URL, an archive member or a database row. The / operator joins paths; it doesn’t enforce that the result stays inside BASE. Serving files from an uploads folder, extracting archives, and building per-tenant storage paths all need an explicit containment check, and interviewers for backend roles ask how you’d write it because the naive versions are exploitable.

The examples run on Python 3.14 on macOS against a small directory tree: uploads/reports/q3.txt, a secret/.env beside uploads/, and a symlink uploads/reports/link.txt pointing at that .env. Absolute paths in the output are shortened to /srv/app.

Three ways the join escapes

from pathlib import Path
BASE = Path("uploads").resolve()

print(BASE / "reports/q3.txt")
print(BASE / "/etc/passwd")
print(BASE / "../secret/.env")
print((BASE / "../secret/.env").exists())
python
/srv/app/uploads/reports/q3.txt
/etc/passwd
/srv/app/uploads/../secret/.env
True
Text

The second line is the surprising one. Joining an absolute path discards everything before it, exactly as os.path.join does. A request for ?file=/etc/passwd produces /etc/passwd, and your base directory played no part.

The third line shows ... pathlib doesn’t collapse .. when joining, because doing that correctly requires knowing about symlinks, so the path looks like it’s under uploads/ when printed but refers to secret/.env on disk. Any check that looks at the string prefix of this unresolved path is fooled.

The third escape is a symlink. uploads/reports/link.txt is a perfectly innocent-looking name, but reading it returns the contents of secret/.env:

API_KEY=fake
Text

Symlinks get into upload directories more often than you’d think: through archive extraction (tar files can contain symlinks), shared volumes, or an attacker who has write access to one directory and wants to read another.

The check that looks right and isn’t

A common first attempt resolves the path and compares strings:

def unsafe_startswith(name):
    p = (BASE / name).resolve()
    return str(p).startswith(str(BASE))

print(unsafe_startswith("../uploads-old/x.txt"))
python
True
Text

/srv/app/uploads-old/x.txt starts with the string /srv/app/uploads. The check accepts a sibling directory. Adding a trailing slash to the prefix patches this case, but string comparisons of paths keep breaking on details like case-insensitive filesystems and Windows drive letters. Compare paths as paths.

A containment check that holds up

The approach that works has two steps: resolve the joined path to an absolute, symlink-free, ..-free form, then ask whether it’s inside the resolved base.

def safe_path(name: str) -> Path:
    candidate = (BASE / name).resolve()
    if not candidate.is_relative_to(BASE):
        raise PermissionError(f"{name!r} resolves outside {BASE.name}/")
    return candidate

for name in ["reports/q3.txt", "../secret/.env", "/etc/passwd",
             "reports/link.txt", "reports/../reports/q3.txt"]:
    try:
        print("ok  ", safe_path(name).relative_to(BASE))
    except PermissionError as e:
        print("deny", e)
python
ok   reports/q3.txt
deny '../secret/.env' resolves outside uploads/
deny '/etc/passwd' resolves outside uploads/
deny 'reports/link.txt' resolves outside uploads/
ok   reports/q3.txt
Text

Each decision deserves a sentence.

  • Resolve the base too. BASE = Path("uploads").resolve() makes it absolute and removes any symlinks in its own path. If you compare a resolved candidate with an unresolved base (say the base is /var/www/uploads but /var is a symlink to /private/var, as on macOS), every legitimate file fails the check.
  • Resolve the candidate. resolve() makes the path absolute, follows symlinks and collapses ... That’s what caught link.txt: it resolved to secret/.env. Since Python 3.6, resolve() works on paths that don’t exist yet (the default strict=False), so you can use the same check before creating a file.
  • Use is_relative_to (Python 3.9+). It compares path components, so uploads-old is not inside uploads. On older versions, candidate.relative_to(BASE) inside a try gives the same answer by raising ValueError.
  • Allow harmless ... reports/../reports/q3.txt stayed inside, so it’s accepted. Rejecting any name containing .. is a blunt alternative that also blocks legitimate names like notes..txt.

Two other inputs are worth handling. Names with a null byte make open() raise ValueError: embedded null byte (and resolve() raises a ValueError too), so catch that and treat it as a bad request rather than a server error. And on Windows, PureWindowsPath("uploads") / "C:evil.txt" produces C:evil.txt: a drive-qualified name also replaces the base. The resolve-and-compare check handles it, which is another reason to prefer it over hand-written string filters.

What resolve-then-check doesn’t cover

The check and the open() are two separate system calls. Between them, someone with write access to the directory could replace reports/q3.txt with a symlink to /etc/shadow. This is a time-of-check to time-of-use (TOCTOU) race. For most web apps, where users can’t create symlinks on the server, the check above is enough. Where they can (shared hosting, extracting user-supplied archives), defend in layers:

  • Don’t use user input as a path at all. Store uploads under generated names (uuid4().hex) and keep the original filename in a database column. Then the “path” a user supplies is an id you look up, and traversal isn’t possible.
  • When extracting tar files, use tarfile’s extraction filters. Python 3.14 defaults to the "data" filter, which rejects absolute paths, .. members and links pointing outside the destination. Older versions need filter="data" passed explicitly.
  • Run the process with an OS user that can only read the uploads directory, so a mistake in path handling can’t read secrets anyway.
  • On Linux, opening relative to a directory file descriptor with O_NOFOLLOW (via os.open(..., dir_fd=...)) closes the race, at the cost of more code.

Small pathlib details that cause bugs

A few non-security behaviours trip people up in the same code:

print(Path("report.tar.gz").suffix, Path("report.tar.gz").suffixes, Path("report.tar.gz").stem)
print(sorted(p.name for p in BASE.glob("**/*.txt")))
python
.gz ['.tar', '.gz'] report.tar
['link.txt', 'q3.txt']
Text

suffix is only the last extension, so an “allowed types” check on .suffix == ".gz" says nothing about what’s inside. And glob("**/*.txt") happily returns symlinks, so a “list all reports” endpoint can leak the same file the containment check was meant to protect. Filter glob results through safe_path too, or skip entries where p.is_symlink() is true.

Interview practice: the download endpoint

A Flask route does return send_file(Path("/srv/files") / request.args["name"]). Describe the attacks it allows and write a fixed version.

Answer

The parameter controls the path completely. name=/etc/passwd replaces the base because joining an absolute path discards the left side; name=../app/config.py walks out with ..; and if anyone can create symlinks under /srv/files, a link there reaches any file the process can read. The fixed version resolves both paths and checks containment before touching the file:

BASE = Path("/srv/files").resolve()

@app.get("/download")
def download():
    try:
        target = (BASE / request.args["name"]).resolve()
    except (KeyError, ValueError):
        abort(400)
    if not target.is_relative_to(BASE) or not target.is_file():
        abort(404)
    return send_file(target)
python

Returning 404 for both “outside” and “missing” avoids telling an attacker which paths exist. I’d also mention that Flask ships send_from_directory, which applies werkzeug.security.safe_join for exactly this case, and that the strongest design avoids user-chosen paths altogether by storing files under generated ids.

More in Python

read ✓Python · hard

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readread →
esc