Your web app keeps uploads under /srv/data, and each request names the
file it wants. You join the name onto the folder and check that the result
starts with /srv/data. A request for ../data2/secrets.env passes that
check and reads a file outside the folder. The pathlib module gives you
path objects with a method that makes the right check.
Start with the parts of a path:
from pathlib import PurePosixPath
path = PurePosixPath("/srv/reports/archive.tar.gz")
print(path.parent) # /srv/reports
print(path.name) # archive.tar.gz
print(path.stem) # archive.tar
print(path.suffix) # .gz
print(path.suffixes) # ['.tar', '.gz']
print(path.with_suffix("")) # /srv/reports/archive.tar
PurePosixPath handles paths written with forward slashes, as on Linux and
macOS. A "pure" path works only on the text of the path and never touches the
disk, so it behaves the same on every machine. For real files, use Path.
It adds methods such as exists, mkdir, read_text and write_text.
Pass encoding="utf-8" when you read or write text.
stem is only the last part of the path, without its last suffix. To drop
the suffix and keep the folders, use with_suffix("").
An absolute part replaces the path before it
The / operator joins paths:
root = PurePosixPath("/srv/data")
print(root / "reports/q1.csv") # /srv/data/reports/q1.csv
print(root / "/etc/passwd") # /etc/passwd
print(root / "../other") # /srv/data/../other
When the right side is absolute, it replaces everything before it. That is
normal path joining, but when the part comes from a user, the result can
leave your folder. Joining also keeps .. as it is.
Clean up the path, then compare whole parts
posixpath.normpath removes . and .. from a path string. Then
is_relative_to checks whether one path is inside another:
import posixpath
candidate = PurePosixPath(posixpath.normpath(str(root / "a/../b")))
print(candidate) # /srv/data/b
print(candidate.is_relative_to(root)) # True
sibling = PurePosixPath("/srv/data2/secrets.env")
print(str(sibling).startswith(str(root))) # True, which is wrong
print(sibling.is_relative_to(root)) # False
A common mistake is a string prefix check. The string /srv/data2 starts
with /srv/data, but the folder data2 is not inside data. The
is_relative_to method compares whole parts, so it sees data and data2
as different names. A path counts as relative to itself, so the root passes.
A check that looks only at the text of a path, like these two, is a lexical check.
Check your understanding Does this check make it safe to open the file? Show answer Hide answer
Not on its own. A symlink, a file that points to another path, can be
inside the root and point outside it, and a lexical check cannot see that.
Path.resolve() follows symlinks, so comparing resolved paths checks where
they point right now. The disk can still change between your check and your
open. For files that untrusted users can create, also control who can
write to that folder. The exercise checks only the text of the path.
Going further
Path.absolute()makes a path absolute without removing..or following symlinks.Path.resolve()does both.- A public function that accepts both strings and path objects can annotate
the parameter as
str | os.PathLike[str]and convert it withPath(...). - Python 3.14 changed
suffixfor a name that ends in a dot.PurePosixPath("file.").suffixis"."from 3.14 on, and""on 3.12 and 3.13. - See the pathlib documentation.