skip to content

Directory Traversal Defenses

Keeping a user-supplied path inside one directory: resolve before you check, the join that silently discards everything before an absolute component, symlinks that escape, and the check-then-open gap.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why does os.path.join('/srv/uploads', name) not guarantee a path inside /srv/uploads?

level: juniorimportance: must knowfreq 55%

answer

  1. Joining builds a string, it does not confine
  2. Watch what a leading separator does
  3. An absolute component discards the base
  4. '..' escapes even with no absolute component
  5. os.path.basename when one flat filename suffices

basics

~20 s

os.path.join throws away everything to the left of a component that is already absolute, so joining '/etc/passwd' onto a base returns '/etc/passwd'. A relative name holding '..' also climbs out. Joining concatenates; it never confines.

solid answer

~30 s

`os.path.join` walks its arguments left to right and restarts whenever it meets an absolute component, so `os.path.join('/srv/uploads', '/etc/passwd')` is `'/etc/passwd'` — the base silently disappears. The `/` operator on `pathlib.Path` behaves identically: `Path('/srv/uploads') / '/etc/passwd'` is `PosixPath('/etc/passwd')`. Even without an absolute component, a name such as `'../../etc/passwd'` joins fine and then resolves outside the base. So joining is string assembly, not a security boundary. If the endpoint only ever needs one flat filename, reduce the input with `os.path.basename` first; if nested subpaths are legitimate, join, then resolve the result and prove containment before touching the filesystem.

code

pycon · 8 lines
pycon
>>> import os.path
>>> os.path.join('/srv/uploads', 'report.csv')
'/srv/uploads/report.csv'
>>> os.path.join('/srv/uploads', '/etc/passwd')
'/etc/passwd'
>>> from pathlib import Path
>>> Path('/srv/uploads') / '/etc/passwd'
PosixPath('/etc/passwd')

go deeper

for a junior

Recall the two escapes verbatim: an absolute second component makes the base disappear, and .. climbs out even when nothing is absolute. Be able to say what os.path.join('/srv/uploads', '/etc/passwd') returns without hesitating.

for a middle

Explain the left-to-right restart rule and show that pathlib's / shares it. Then pick the right remedy for the input shape: os.path.basename for a flat filename, resolve-then-containment when nested paths are legitimate.

for a senior

Demonstrate that you validate the joined outcome rather than the raw input, that the validated value is the exact value later opened, and that the base directory itself is an absolute constant rather than something derived from the working directory.

for a principal

Own the standard: make the safe helper the only sanctioned way to turn user input into a path in the codebase, back it with a test that feeds absolute and dot-dot inputs, and prefer an opaque-identifier indirection so user text never becomes a path component at all.

## Joining is assembly, not confinement `os.path.join` has one job: build a path string out of components using the platform separator. It is not a validator and it never consults the filesystem. Its documented rule is the one that surprises people: it processes its arguments left to right, and **whenever a component is absolute, everything accumulated before it is discarded** and assembly restarts from that component. So the base directory a reviewer sees in the source can vanish at runtime purely because of what the user typed. ```pycon >>> import os.path >>> os.path.join('/srv/uploads', 'report.csv') '/srv/uploads/report.csv' >>> os.path.join('/srv/uploads', '/etc/passwd') '/etc/passwd' ``` The `pathlib` equivalent is not safer. The `/` operator on a `PurePath` follows the same rule, because it is the same joining semantics wearing an object-oriented coat: ```pycon >>> from pathlib import Path >>> Path('/srv/uploads') / '/etc/passwd' PosixPath('/etc/passwd') ``` ## Two separate escapes, one API There are two distinct ways a joined path leaves the base, and a candidate should be able to name both. 1. **The absolute component.** The attacker supplies a leading separator. `os.path.isabs` on the user segment is exactly the predicate that is true here, and the join drops the prefix. 2. **The relative climb.** The attacker supplies `../../etc/passwd`. Nothing is discarded — the string `'/srv/uploads/../../etc/passwd'` is produced faithfully — but the kernel, or `os.path.normpath`, walks it up out of the base. Note that `..` survives arbitrary nesting and arbitrary interleaving (`a/../../..`), which is why substring filters for the literal `'../'` are a losing game. On Windows a third case appears: a component can be absolute on the drive but drive-relative, so `ntpath`-style joining of `'C:/base'` with a root-anchored second component keeps the drive and replaces the rest. Windows also accepts `/` as an alternate separator (`os.altsep`), so a filter written around backslashes misses half the input. ## What actually confines a path **Decide first whether nesting is legitimate.** Most upload and download endpoints need a single flat filename, and for those `os.path.basename` is the cheapest correct answer: it returns `'passwd'` for both `'/etc/passwd'` and `'../../etc/passwd'`, collapsing the whole attack class into a name with no separators at all. Pair it with an allow-list of permitted characters or an extension check, and reject an empty result. When genuine subdirectories are part of the feature, joining is only step one. Join, then normalize the result against the real filesystem, then prove the outcome is under the base — in `pathlib` terms, call `Path.resolve` on the joined candidate and test it with `PurePath.is_relative_to` against a base you resolved once at start-up. The ordering matters: the containment test is a lexical comparison, so it is only meaningful on paths that have already been made absolute and had `..` and symlinks resolved out of them. ## Adjacent traps worth knowing - **A NUL byte is not a truncation trick in Python.** Passing a path containing an embedded null byte to an OS call raises `ValueError` rather than silently truncating at the C string boundary, so the classic `"safe.txt\x00../../etc/passwd"` shape fails loudly instead of exploiting. - **`os.path.expanduser` is a separate expansion.** A leading `~` is not special to `os.path.join`, but if the code later calls `expanduser` on user input, the path can jump to a home directory that the base check never anticipated. - **Trailing components matter on Windows.** Reserved device names, trailing dots and trailing spaces are normalized by the OS after your check has run, so a Windows deployment needs its own name allow-list. - **The check must run on the same string the open runs on.** If validation happens on a decoded copy and the open happens on the original, the two can disagree; keep one canonical value and pass that value everywhere. ## What an interviewer is listening for The crisp answer is one sentence — "an absolute second component discards the base, and `..` climbs out anyway" — followed by the correct remedy for the shape of the input. Candidates who claim `os.path.join` sanitizes anything, or who reach for a `replace('../', '')` filter, are describing a control that has been broken since the day it was invented: `....//` collapses back to `../` after a single non-recursive pass.

  • Does pathlib's `/` operator protect you where os.path.join does not?
    No. `PurePath.__truediv__` applies the same rule: joining an absolute right-hand operand replaces the left one entirely, so `Path('/srv/uploads') / '/etc/passwd'` is `PosixPath('/etc/passwd')`. `pathlib` gives you better tools for the *check* — `Path.resolve` plus `PurePath.is_relative_to` — but the join itself is exactly as permissive as the `os.path` version.
  • When is os.path.basename enough, and when is it not?
    It is enough when the feature only ever addresses one file in one directory: it strips every separator and every `..`, leaving a bare name. It is not enough when nested subdirectories are legitimate, because it destroys the nesting you need; there you must keep the relative path and prove containment after resolving. It is also not a defence against a name that is itself dangerous — a symlink already sitting in the directory, or a reserved device name on Windows.
  • Why does filtering the substring '../' out of the input fail?
    Because a single non-recursive pass creates new sequences: `'....//'` becomes `'../'` after the inner `'../'` is removed. Separators also vary — Windows accepts backslash, and encodings or URL decoding can reintroduce the sequence after the filter ran. Any deny-list over path syntax is a guess at the parser's grammar; canonicalizing and then testing containment tests the actual outcome instead.

Joining is like taping an address label onto an envelope: if the label already carries a full address of its own, the tape does nothing to keep the letter in your building.

saying these in an interview costs you the question

  • Claims os.path.join sanitizes or confines user input
  • Strips '../' from the string and calls it fixed
  • Thinks pathlib's / operator rejects absolute right-hand operands
  • Checks the input string instead of the joined result
  • Believes an embedded null byte truncates the path in Python

context

open as a page

How do you verify with pathlib that a user-supplied path stays inside a base directory?

level: middleimportance: must knowfreq 65%

basics

~20 s

Join 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.

open as a page

How do os.path.normpath, os.path.abspath and os.path.realpath differ when confining a user path?

level: middleimportance: should knowfreq 45%

basics

~20 s

normpath collapses '.' and '..' purely textually with no filesystem access; abspath is normpath anchored to the current working directory; only realpath walks the filesystem and resolves symlinks. A containment check built on the first two can approve a symlinked escape.

open as a page

How does the gap between validating a path with Path.resolve and opening it allow an escape?

level: seniorimportance: should knowfreq 32%

basics

~20 s

The check and the open are two independent name lookups. Between them an attacker can replace a component with a symlink or rename a directory, so the second lookup reaches a different file than the one that was validated. Close it by opening through a directory file descriptor.

open as a page