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())/srv/app/uploads/reports/q3.txt
/etc/passwd
/srv/app/uploads/../secret/.env
TrueThe 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=fakeSymlinks 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"))True/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)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.txtEach 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/uploadsbut/varis 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 caughtlink.txt: it resolved tosecret/.env. Since Python 3.6,resolve()works on paths that don’t exist yet (the defaultstrict=False), so you can use the same check before creating a file. - Use
is_relative_to(Python 3.9+). It compares path components, souploads-oldis not insideuploads. On older versions,candidate.relative_to(BASE)inside atrygives the same answer by raisingValueError. - Allow harmless
...reports/../reports/q3.txtstayed inside, so it’s accepted. Rejecting any name containing..is a blunt alternative that also blocks legitimate names likenotes..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 needfilter="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(viaos.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"))).gz ['.tar', '.gz'] report.tar
['link.txt', 'q3.txt']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)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.