Keep file paths inside their folder
When a file name comes from outside, `..` and absolute paths can point anywhere on the machine. Resolve the full path and refuse it unless it is still inside the folder you meant.
- Show how
..and absolute names escape a base folder when joined with/ - Resolve the joined path and check
is_relative_to(base)(not a text prefix) before opening it - Check every member of an archive before writing any of them (zip slip)
Programs often take a file name from a request: *download invoice-0412.pdf*, *save this upload as march.csv*. Joining that name to a folder looks safe: Path("uploads") / name. But a name is a path, and paths can climb. ../config/settings.json goes up one level and into another folder; reports/../../secret.txt does the same in disguise; and joining an absolute path like /etc/passwd throws the base away completely. This is path traversal.
The defence: build the path, resolve it (resolve() turns it into an absolute path with every .. worked out and symbolic links followed), and accept it only if it is_relative_to the resolved base folder. Comparing the actual resolved location is what counts, not what the text looks like.
Run each example to see what it prints. Running examples doesn't count toward progress.
from pathlib import Path base = Path("uploads").resolve() names = ["invoice-0412.pdf", "2025/march.csv", "../config/settings.json", "reports/../../secret.txt", "/etc/passwd"] for name in names: target = (base / name).resolve() print(f"{name:28} inside: {target.is_relative_to(base)}") print(Path("uploads") / "/etc/passwd")
A safe opener raises instead of returning a path outside the base, so no code path can accidentally open it:
from pathlib import Path def safe_path(base, name): root = Path(base).resolve() target = (root / name).resolve() if not target.is_relative_to(root): raise ValueError(f"{name!r} is outside {base}") return target print(safe_path("invoices", "../app/settings.py"))
Why not check the text, for example refuse names containing ..? Because text checks miss things (an absolute path has no ..; ....// tricks; encoded forms that are decoded later) and wrongly refuse harmless names (notes..txt). And why not str(target).startswith(str(base))? Because /srv/uploads_old/x starts with /srv/uploads. is_relative_to compares whole path components.
from pathlib import Path def read_report(base, name): root = Path(base).resolve() target = (root / name).resolve() if not target.is_relative_to(root) or not target.is_file(): return None return target.read_text(encoding="utf-8") Path("reports").mkdir(exist_ok=True) Path("reports/march.csv").write_text("order_id,total\nO100282,35.84\n", encoding="utf-8") Path("secret.env").write_text("PAYMENT_API_KEY=sk_live_51HxQe2Kd9f2a\n", encoding="utf-8") print(read_report("reports", "march.csv"), end="") print(read_report("reports", "../secret.env")) print(read_report("reports", "april.csv"))
When to use it: whenever any part of a path comes from outside: downloads, uploads, report names, archive members, template names. Mistakes to avoid: joining without resolving; checking the text for .. instead of the resolved location; startswith on path strings; and checking archive members one by one *while* extracting, so the first good files are already written when a bad one is found.
- A file name from outside is a path:
..climbs and an absolute name replaces the base. - Resolve the joined path and accept it only if it
is_relative_tothe resolved base; never trust text checks orstartswith. - For archives, check every member before writing anything.
- Next module: once code is safe, you need to see what it does in production. The Observability & Operations module: observability.