Skip to content

← Stdlib Mastery step 43 of 55

Easy Primitives

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.