Skip to content

← Everyday Standard Library Tools step 45 of 55

Easy Primitives

Keep a Joined Path Inside Its Root Folder

Write safe_join(root, *parts), which joins path parts onto a root folder and rejects any result outside that folder. A web app can use it to stop a request for ../../etc/passwd from reading files outside its upload folder. Work only with the text of the paths. Do not touch the disk or follow symlinks.

safe_join(PurePosixPath("/srv/data"), "a", "..", "archive.tar.gz")
# PurePosixPath('/srv/data/archive.tar.gz')
safe_join(PurePosixPath("/srv/data"), "/etc/passwd")
# raises ValueError

What to write

Fix safe_join(root, *parts) in the starter:

  1. Join the parts with root.joinpath(*parts).
  2. Pass the joined path's string to posixpath.normpath, and build a PurePosixPath called candidate from the result.
  3. If candidate is not inside root, raise ValueError. Check with candidate.is_relative_to(root). Otherwise return candidate.

The starter already does steps 1 and 2, but it checks with startswith, which treats /srv/data2 as inside /srv/data.

Then write solve(root, parts):

  • Clean up the root first with base = PurePosixPath(posixpath.normpath(root)), and pass base to safe_join.
  • On success, return {"path": str(joined), "suffix": joined.suffix, "parent": str(joined.parent)}.
  • On ValueError, return exactly {"path": "ValueError", "suffix": "", "parent": ""}.

For root "/srv/data" and parts ["a", "..", "archive.tar.gz"], solve returns path "/srv/data/archive.tar.gz", suffix ".gz" and parent "/srv/data".

Edge cases

  • An absolute part replaces the path before it. Do not reject a part for being absolute. Check only the final path. Under /srv/data, accept /srv/data/file.txt and reject /etc/passwd.
  • Accept a path that goes up and comes back into the root, such as a/../b.
  • With no parts, return the root itself. is_relative_to counts a path as relative to itself.
  • Accept empty parts and . parts.
  • /srv/data2 is outside /srv/data.
  • A relative root follows the same rules. Do not turn it into an absolute path.
  • Use the suffix that your Python version gives. From Python 3.14, a name that ends in a single dot has the suffix ".".