How do os.path.normpath, os.path.abspath and os.path.realpath differ when confining a user path?
answer
- Sort the three by whether they touch the disk
- Only one of them issues system calls
- '..' means something different to the kernel
- A symlink parent is not the name's parent
- Same-file questions want device and inode
basics
~20 snormpath 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.
solid answer
~40 s`os.path.normpath` is a string transformation: it collapses `.`, `..` and duplicate separators without ever touching the disk. `os.path.abspath` is `normpath` applied after joining the path to `os.getcwd()`, so it adds anchoring but no filesystem knowledge. `os.path.realpath` is the only one that reads the filesystem, resolving each symlink component with the kernel's own rules and returning the canonical location; `Path.resolve` is its `pathlib` equivalent. The distinction is a security boundary, because lexical `..` collapsing is *wrong* in the presence of symlinks: if `uploads/link` points at `/var`, then `uploads/link/..` is `/` to the kernel but `uploads` to `normpath`. Build containment checks on `realpath` or `Path.resolve`, and accept that they cost filesystem calls and describe only the filesystem's current state.
code
pycon · 7 lines>>> import os.path
>>> os.path.normpath('/srv/uploads/../../etc/passwd')
'/etc/passwd'
>>> os.path.abspath('/srv/uploads/../etc/passwd')
'/srv/etc/passwd'
>>> os.path.normpath('/srv//uploads/./reports')
'/srv/uploads/reports'go deeper
Remember the one-line sort: normpath and abspath are string functions, realpath asks the filesystem. Security checks use realpath or Path.resolve; the other two are for tidying a path up for display.
Explain that abspath is just normpath anchored to the working directory, and give the concrete symlink case where lexical '..' collapsing produces the wrong destination. Know that realpath's strict parameter arrived in 3.10 and defaults to off.
Weigh realpath's per-component syscall cost against the validation rate on a hot path, and be explicit that its answer describes the filesystem only during the call — so it belongs before a file-descriptor-based open, not instead of one.
Decide where canonicalization belongs architecturally: whether services accept paths at all, whether the base tree is writable by anything less trusted, and whether the platform should hand out opaque handles so no component of a user string ever reaches a filesystem walk.
## Three functions, one axis: does it touch the disk? - **`os.path.normpath(p)`** — pure text. It collapses `.` segments, resolves `..` by deleting the preceding component, squeezes repeated separators and, on Windows, converts `/` to `\`. It performs no system calls and does not care whether any component exists. - **`os.path.abspath(p)`** — `normpath(join(os.getcwd(), p))`. It guarantees an absolute result, so its answer depends on the process's current working directory, but the normalization it applies is still purely lexical. - **`os.path.realpath(p)`** — canonicalization against the real filesystem. It walks the path component by component, reading each symlink it meets (and following the chain, with loop detection) until it has the actual location. `pathlib.Path.resolve` is the same idea in the object API. ```pycon >>> import os.path >>> os.path.normpath('/srv/uploads/../../etc/passwd') '/etc/passwd' >>> os.path.abspath('/srv/uploads/../etc/passwd') '/srv/etc/passwd' ``` ## Why lexical `..` is not merely weaker — it is wrong The kernel resolves a path left to right, and `..` means "the parent of wherever I currently am", *after* any symlink has been followed. A lexical normalizer means "delete the previous component of the string". Those two definitions agree only when no symlink is involved. Suppose a base directory contains an entry `link` that is a symlink to `/var`. The path `base/link/../secrets` reaches `/secrets` in the kernel's walk: it enters `/var`, goes up to `/`, then descends into `secrets`. `normpath` deletes the `link` component and reports `base/secrets` — comfortably inside the base. A containment check built on `normpath` therefore **approves** a path that will open a file outside the base. That is not a subtle weakness; it is the check returning the wrong answer for the exact case the attacker constructs. The practical consequence for a validator: if any part of the base directory tree is writable by anything less trusted than the service — an upload area, a shared scratch directory, a mount managed elsewhere — an attacker can plant the symlink and lexical normalization will wave the request through. ## What realpath costs you `realpath` is not free. It issues `lstat`-style calls per component, so a hot path validating many names pays real syscalls, and on a network filesystem those can be slow. It also fails open in a specific sense: by default a component that does not exist is simply carried through rather than raising, which is usually what you want for a create-path. Its `strict` parameter, added in **Python 3.10**, turns a missing or unreadable component into an `OSError`; `Path.resolve(strict=True)` is the `pathlib` spelling. Both defaults are non-strict. The deeper limit is temporal: `realpath` reports the filesystem as it is during the call. The moment it returns, the mapping from name to inode can change. That is why a resolved-and-checked path is a *necessary* control, not a sufficient one, and why high-assurance code opens through a directory file descriptor instead of re-walking the name. ## Where each one is the right tool - Use **`normpath`** for display, deduplication and caching keys — cleaning up a path for a log line or comparing two configuration entries. Never for a security decision. - Use **`abspath`** when you need an absolute path and are certain no symlink is in play, for example expanding an operator-supplied argument at start-up. Remember that its answer moves if anything calls `os.chdir`. - Use **`realpath`** or **`Path.resolve`** whenever the answer feeds a containment check, a comparison of two paths for identity, or an authorization decision. A related identity question is worth keeping distinct: to ask whether two paths name *the same file*, comparing canonical strings is second best. `os.stat` both and compare with `os.path.samestat`, which tests the device and inode numbers — that survives hard links, which no amount of string canonicalization can see, and hard links are the one escape `realpath` cannot detect because there is no link to follow. ## The interview-sized summary Two of them are string functions and one is a filesystem function; the security answer is the filesystem one. Being able to state *why* the lexical version is not just weaker but actively incorrect — the parent of a symlink is not the parent of its name — is what separates a memorized answer from an understood one.
- Why can os.path.normpath approve a path that escapes the base directory?Because it resolves `..` by deleting the previous text component, while the kernel resolves it as the parent of the directory it actually reached. If a component is a symlink to somewhere else, the two disagree: `base/link/..` is `base` to normpath and the parent of the link's target to the OS. The check then passes on a path that opens outside the base.
- How would you test whether two paths name the same file?Call `os.stat` on both and compare the results with `os.path.samestat`, which matches on device and inode. String comparison of canonical paths misses hard links entirely — two names in different directories can share one inode with no symlink to follow — and it is also sensitive to case and trailing-separator differences that the filesystem does not care about.
- What does realpath do with a component that does not exist?By default it carries the non-existent tail through and returns without error, which is what you want when validating a path you are about to create. Since Python 3.10 you can pass `strict=True` to raise `OSError` instead; `Path.resolve(strict=True)` is the equivalent. Non-strict is the default in both.
Lexical normalization is like retracing a route on a map by erasing the last street you wrote down; the filesystem walk is like actually driving it, and discovering that one of the streets was a teleporter.
saying these in an interview costs you the question
- Treats normpath as a security control over user paths
- Believes abspath resolves symlinks because it returns an absolute path
- Says lexical '..' collapsing matches how the kernel walks a path
- Compares canonical strings to decide two paths are the same file
- Assumes realpath's result stays true after the call returns