We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Stdlib Mastery step 43 of 55
pathlib: safe_join, or how to not serve /etc/passwd
Join untrusted components onto a trusted root, and refuse anything that escapes it.
def safe_join(root: PurePosixPath, *parts: str) -> PurePosixPath: ...
def solve(root: str, parts: list[str]) -> dict[str, object]: ...
safe_join joins parts onto root, normalises the result lexically, and
raises ValueError if it is not inside root. solve normalises the root,
calls safe_join, and returns:
{"path": str, "suffix": str, "parent": str}
or {"path": "ValueError", "suffix": "", "parent": ""} when the join is
rejected.
Use PurePosixPath throughout — pure paths do no filesystem access, so
the result is identical on every operating system.
The one-line vulnerability. Path("/srv/data") / "/etc/passwd" is
/etc/passwd: when the right-hand side is absolute, / discards the left
side entirely. One leading slash in a request parameter, an upload filename
or a config value and your carefully chosen root is gone. .. is the other
half — Path does not normalise it on its own, because resolving ..
correctly requires knowing whether the preceding component is a symlink, so it
is kept as a literal component and only bites when something resolves it
later.
Compare components, not strings.
"/srv/data2/secrets.env".startswith("/srv/data") is True, and /srv/data2
is a different directory — an attacker who can create a sibling directory
whose name extends yours walks straight through a prefix check.
PurePosixPath.is_relative_to compares the parsed component tuples and gets
the trailing-slash and duplicate-slash cases right for free. The general rule:
paths are sequences of components that happen to have a string rendering, and
every check written against the rendering has a counterexample.
What this cannot catch. A symlink inside the root pointing outside it is
an escape that no amount of lexical normalisation can see. Path.resolve()
follows symlinks — and touches the filesystem, which is why it belongs in the
layer that actually opens the file, on top of this check, not instead of it.
(absolute() is not a substitute: it prepends the cwd and resolves neither
.. nor symlinks.)
One suffix note the tests check: .suffix is the last extension, so
archive.tar.gz has suffix .gz and stem archive.tar.
Typing: accept str | os.PathLike[str] at real public boundaries and
normalise immediately — refusing the union makes your function less usable
than the standard library for no benefit.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.