Path Traversal: The Complete Practical Guide
Preventing Path Traversal
How to stop path traversal: map ids to paths, canonicalize then verify inside the base, allow-list filenames, decode before validating, and least privilege.
Published
Every path traversal comes from the same mistake: user input reaching a filesystem path without the result being confirmed to stay inside the intended directory. So the fix is singular and reliable. Decide the file server-side, or resolve-then-contain, and the whole class disappears. This page covers the controls in priority order. Build the habit on a free live target.
The strongest fix: do not take a path from the user
Prefer an indirect map. Accept an id or a short key and look up the real path server-side, so user input never touches the filesystem path at all:
# safe: the user picks a key, not a path
FILES = {"invoice": "/var/www/files/invoice.pdf", "report": "/var/www/files/report.pdf"}
path = FILES.get(request.args["doc"])
if path is None:
abort(404)
return open(path).read()The user can send anything, but only keys in the map resolve, and nothing they send is ever concatenated into a path. This removes traversal entirely for the common "pick a document" feature.
If you must take a path: canonicalize, then verify containment
When a real path has to come from the user, resolve it to its canonical absolute form and confirm it is still inside the base directory before opening:
import os
base = os.path.realpath("/var/www/files") # resolve the base too, in case it contains a symlink
requested = os.path.realpath(os.path.join(base, request.args["file"]))
# positive check: did we end up INSIDE base?
if not requested.startswith(base + os.sep):
abort(403)
return open(requested).read()realpath collapses ../, symlinks, and redundant separators, so you are validating where the path actually ends up, not what it looks like. The containment check is a positive assertion, which is why it beats blocklists. In other stacks use Path.GetFullPath (.NET) or path.resolve plus a prefix check (Node).
Allow-list the filename and extension
Layer a strict input check on top: permit only the characters and extensions you expect, and reject anything with a separator, a .., or an encoded variant. Allow-list what is valid rather than blocklisting what is bad, because, as the bypass spokes show, you cannot enumerate every traversal and encoding trick.
Decode once, completely, before validating
Normalize and fully decode the input before any check runs, so a double-encoded sequence cannot reconstruct a ../ after your filter has already passed it. Validate the final, decoded, canonical form, never the raw request string.
Why the popular half-measures fail
- Stripping
../is defeated by nested....//and encoding. Do not strip, validate. basename()alone is easy to apply too late or pair with a user-controlled directory.- Blocklists and WAF keyword rules are a speed bump, and encodings and absolute paths route around them.
These are not defenses, they are obstacles, and the bypass spokes exist precisely because teams rely on them.
Harden the runtime
Assume a bug slips through and make the read worthless:
- Least privilege: the web user should not be able to read
/etc/shadow, SSH keys, or other applications' files. - chroot or containerize the service so even a successful traversal reaches an empty jail.
- Keep secrets out of the readable filesystem where you can (a secrets manager rather than a world-readable
.env).
A short checklist
- Map an id or key to a fixed path. Do not concatenate user input into a path.
- If you must take a path,
realpath/GetFullPathit and assert it stays inside the base directory. - Allow-list the filename and extension. Reject separators,
.., and encoded variants. - Decode and normalize fully before validating.
- Never strip-and-trust. Validate the final resolved path.
- Run least-privilege and contained so a successful read hits nothing valuable.
That is the whole defense. For the offensive side in one place, see the path traversal cheat sheet, and to prove to yourself that a containment check really does close the bug, break a concatenated-path version on a free live target.