How do you verify with pathlib that a user-supplied path stays inside a base directory?
answer
- Two steps, and their order is the whole trick
- Canonicalize before you compare
- String prefixes ignore component boundaries
- Resolve the base once at start-up
- Open the resolved value, not the input
basics
~20 sJoin the user segment onto an absolute base, call Path.resolve on the result, and test it with PurePath.is_relative_to against the base you resolved once at start-up. Resolve first, compare second, and never compare raw strings.
solid answer
~40 sResolve, then compare. `Path.resolve` makes the candidate absolute, collapses `.` and `..`, and follows symlinks, so it answers what the filesystem would actually open; `PurePath.is_relative_to` is then a cheap lexical containment test on two already-canonical paths. Resolve the base once at start-up and keep it as a constant, because `resolve` anchors relative paths to the current working directory. Do not substitute `str.startswith`: `'/srv/uploads-old'` starts with `'/srv/uploads'` yet is a different directory — the comparison must be component-wise, which is what `is_relative_to` and `os.path.commonpath` give you. Finally, use the resolved path for the open; validating one string and opening another reintroduces the hole you just closed.
code
python · 15 linesfrom pathlib import Path
BASE = Path('/srv/uploads').resolve()
def safe_path(user_name: str) -> Path:
candidate = (BASE / user_name).resolve()
if not candidate.is_relative_to(BASE):
raise ValueError('path escapes the base directory')
return candidate
print(safe_path('reports/q3.csv'))
try:
safe_path('../../etc/passwd')
except ValueError as exc:
print('rejected:', exc)go deeper
Recall the two-line recipe: resolve the joined path, then ask whether it is relative to a base you resolved once. Know that comparing path strings with startswith is the wrong answer interviewers listen for.
Explain why resolution must precede comparison, what Path.resolve actually does to '..' and symlinks, and why '/srv/uploads-old' defeats a prefix test. Mention that strict defaults to False so non-existent paths still validate.
Show the helper as the single sanctioned entry point that returns the resolved path the caller must open, keep the base an absolute constant immune to os.chdir, and be explicit that containment is a point-in-time statement, not a guarantee held across the later open.
Argue the design one level up: an opaque identifier mapped to a path through a trusted table removes the whole question, and where user paths are unavoidable, the containment helper and its adversarial test suite belong in shared code rather than being re-derived per service.
## The two-step shape of the check Every correct containment check has the same structure: **canonicalize, then compare**, and it compares the thing you are about to open rather than the thing the user sent. ```python from pathlib import Path BASE = Path('/srv/uploads').resolve() def safe_path(user_name: str) -> Path: candidate = (BASE / user_name).resolve() if not candidate.is_relative_to(BASE): raise ValueError('path escapes the base directory') return candidate ``` `Path.resolve` does three things at once: it makes the path absolute (against the process's current working directory, if it was relative), it removes `.` and `..` segments, and it follows symlinks so that the result names the real file the kernel would reach. `PurePath.is_relative_to` then does no I/O at all — it is a pure comparison over path components, returning `True` when the base is a prefix of the candidate's parts. ## Why the order is not negotiable A containment test on unresolved paths is meaningless. `'/srv/uploads/../../etc/passwd'` is *lexically* under `/srv/uploads` — its first components are the base's components — yet it names `/etc/passwd`. Resolving first turns the string into the destination, so the lexical test finally asks the right question. The mirror mistake is resolving the candidate but comparing against an unresolved base: if `/srv/uploads` is itself reached through a symlink, the resolved candidate lives under the *link target* and every legitimate request is rejected. Resolve the base once, at import or start-up, and treat it as a constant. ## Why str.startswith is the wrong comparison The classic broken version is `candidate_str.startswith(base_str)`. String prefixing does not respect path component boundaries: `'/srv/uploads-old/secret'` starts with `'/srv/uploads'` and passes, although it is a sibling directory the base has no authority over. People patch this by appending a separator to the base — which is nearly right, but now the base itself no longer matches, trailing-separator handling differs between the two operands, and on Windows you have two separators and case-insensitive comparison to reason about. `is_relative_to` sidesteps all of it by comparing parsed components. In the `os.path` world the equivalent is `os.path.commonpath`: build the list of the two resolved paths and require the common prefix to equal the base. Beware that `commonpath` raises `ValueError` when it is handed a mix of absolute and relative paths — which is another reason to resolve first. ## `strict`, existence, and what resolve promises `Path.resolve` takes `strict`, and the default is `False`: components that do not exist are permitted, and the path is returned with the non-existent tail attached. That is what you want for a create-file path, and it means the check works before the file is written. With `strict=True` a missing component raises `OSError`, which is useful when you want a read path proven to exist — though it also means your validator now depends on filesystem state. The `os.path` counterpart is `os.path.realpath`, whose own `strict` parameter arrived in **Python 3.10**. ## Case, encoding and the shape of the base On a case-insensitive filesystem two spellings name one file, so a comparison that is case-sensitive can reject valid requests or, worse, mislead a deny-list; `os.path.normcase` exists for exactly this normalization. Keep the base absolute and hard-coded rather than derived from configuration a caller controls, and never derive it from the current working directory, which anything in the process can change with `os.chdir`. ## Version notes worth carrying `PurePath.is_relative_to` arrived in **Python 3.9**. It once accepted extra positional arguments (mirroring `relative_to`); that form was deprecated in **3.12** and **removed in 3.14**, so write the single-argument call. If you must support older interpreters, `PurePath.relative_to` inside a `try`/`except ValueError` is the equivalent, and it has the pleasant side effect of handing you the validated relative segment. ## What the check does not buy you Containment proven at time T is a statement about the filesystem at time T. Between the check and the open, a component can be replaced with a symlink pointing elsewhere; that gap is a separate problem with a separate defence built on directory file descriptors. The check also says nothing about *authorization* — that this user may read this file inside the base — nor about what is already inside the base. It answers exactly one question, and it answers it well: does the path I am about to open live under the directory I intended?
- What breaks if you resolve the candidate but compare it against an unresolved base?Legitimate requests start failing whenever the base is reached through a symlink. The candidate resolves to the link's target — say `/mnt/disk2/uploads/report.csv` — while the base still reads `/srv/uploads`, so the containment test is false for every file in the directory. Resolve the base exactly once at start-up and reuse that constant.
- How would you write the same check with os.path instead of pathlib?Call `os.path.realpath` on the joined candidate and on the base, then require `os.path.commonpath([base, candidate])` to equal the base. That comparison is component-wise, unlike `str.startswith`. Note `commonpath` raises `ValueError` on an empty list or on a mix of absolute and relative paths, so canonicalize both operands before calling it.
- Does Path.resolve require the file to exist?Not by default — `strict` is `False`, so a non-existent trailing component is kept and the call succeeds, which is what you want when validating a path you are about to create. Passing `strict=True` raises `OSError` for a missing component, which is useful for read paths but makes the validator depend on filesystem state.
- After the check passes, which value should the open use?The resolved path the check returned, never the original user string or the unresolved join. If validation runs on one value and the open runs on another, the two can disagree — through a symlink, a case difference, or a re-decoding step — and the check protects nothing. Have the helper return the path and make it the only source the caller has.
saying these in an interview costs you the question
- Compares paths with str.startswith against the base
- Runs the containment test before resolving the candidate
- Resolves the candidate but leaves the base unresolved
- Derives the base from the current working directory
- Validates one path string and then opens a different one
- Assumes the check also survives a later symlink swap