Path traversal: resolve first, then check containment

Path traversal happens when user input reaches a filesystem path, letting `../` sequences, absolute paths, symlinks or URL-encoded separators escape the directory you intended. Filtering `../` out of the input does not work — it can be re-introduced by decoding (`%2e%2e%2f`), by nesting (`....//`), or bypassed entirely with an absolute path. The reliable pattern is to resolve the candidate to a canonical absolute path *first* (`os.path.realpath`, `Path.resolve()`, `fs.realpath`, `File.getCanonicalPath`) and then assert that it starts with the base directory plus a separator. Better still, do not accept a path at all: accept an opaque identifier and look the real filename up in a database.

At a glance

LanguagesEvery language with filesystem access; also archive extraction and template loaders
Payload shapes../, ..\, %2e%2e%2f, ....//, /etc/passwd, C:\Windows\, a symlink pointing outside the base
Safe primitivesPath.resolve + is_relative_to, os.path.realpath + commonpath, fs.realpath + startsWith, File.getCanonicalPath, openat with O_NOFOLLOW
Typical impactReading credentials and keys; arbitrary file write leading to code execution (Zip Slip)
Not a fixinput.replace('../', ''), blocklisting '..', or checking the string before normalising it
RelatedArchive extraction (Zip Slip) is the write-side version of the same bug

Vulnerable and fixed, side by side

Python

Vulnerable

import os

BASE = "/srv/uploads"

def read(name: str) -> bytes:
    # name = "../../etc/passwd" — or "....//....//etc/passwd" after a naive strip
    with open(os.path.join(BASE, name), "rb") as fh:
        return fh.read()

Secure

from pathlib import Path

BASE = Path("/srv/uploads").resolve()

def read(name: str) -> bytes:
    # strict=False so a missing file raises FileNotFoundError below, not here.
    candidate = (BASE / name).resolve()
    if not candidate.is_relative_to(BASE):      # Python 3.9+
        raise PermissionError("outside upload directory")
    if candidate.is_symlink():                  # resolve() already followed it; be explicit
        raise PermissionError("symlinks are not served")
    return candidate.read_bytes()

`os.path.join(BASE, name)` returns `name` verbatim when `name` is absolute — that alone defeats a prefix check written before resolution. Resolve, then compare; `is_relative_to` avoids the classic `/srv/uploads-evil` prefix bug that `startswith(BASE)` has.

Node.js

Vulnerable

app.get("/file", (req, res) => {
  res.sendFile(path.join(UPLOAD_DIR, req.query.name));
});

Secure

const path = require("node:path");
const fs = require("node:fs/promises");

const BASE = path.resolve("/srv/uploads");

app.get("/file", async (req, res) => {
  const raw = String(req.query.name ?? "");
  const candidate = path.resolve(BASE, raw);
  // The separator matters: without it "/srv/uploads-evil" passes the prefix test.
  if (candidate !== BASE && !candidate.startsWith(BASE + path.sep)) {
    return res.status(400).end();
  }
  // realpath resolves symlinks planted inside the base directory.
  const real = await fs.realpath(candidate);
  if (!real.startsWith(BASE + path.sep)) return res.status(400).end();

  res.sendFile(real);
});

Express's `res.sendFile` rejects `..` in a relative path but not in a path you already joined, and it does not resolve symlinks for you. `path.resolve` also normalises `%2e%2e` only after your framework has decoded it — decode once, then resolve.

Java — archive extraction (Zip Slip)

Vulnerable

try (ZipInputStream zis = new ZipInputStream(in)) {
  ZipEntry e;
  while ((e = zis.getNextEntry()) != null) {
    File out = new File(destDir, e.getName());   // e.getName() may be "../../bin/app.sh"
    Files.copy(zis, out.toPath());
  }
}

Secure

Path base = destDir.toPath().toRealPath();
try (ZipInputStream zis = new ZipInputStream(in)) {
  ZipEntry e;
  while ((e = zis.getNextEntry()) != null) {
    Path target = base.resolve(e.getName()).normalize();
    if (!target.startsWith(base)) {
      throw new IOException("zip entry escapes destination: " + e.getName());
    }
    if (e.isDirectory()) { Files.createDirectories(target); continue; }
    Files.createDirectories(target.getParent());
    Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
  }
}

Entry names inside an archive are attacker-controlled strings, not filenames — the ZIP format permits `../`. This is the write-side variant, and it is worse than reading: an entry landing in a cron directory or a web root is code execution.

How to find it in your codebase

  • Grep for user input reaching a path: `rg -n 'os\.path\.join|Path\(|path\.join|new File\(|sendFile|readFile'` and follow each argument back to its source.
  • Semgrep `python.lang.security.audit.path-traversal-open`, `javascript.express.security.audit.express-path-join-resolve-traversal`, `java.lang.security.audit.zip-slip`.
  • CodeQL `py/path-injection`, `js/path-injection`, `java/zipslip` — taint-tracking finds the multi-hop cases a grep will not.
  • Test with the full payload set, not just `../`: URL-encoded, double-encoded, Windows separators, absolute paths, and a symlink planted inside the base directory.

Fix checklist

  1. Prefer an opaque identifier over a path: look the stored filename up by UUID and never let the client name a file.
  2. Where a path is unavoidable, resolve to a canonical absolute path before any check.
  3. Assert containment with a separator-aware comparison (`is_relative_to`, `startsWith(base + sep)`), not a bare prefix match.
  4. Resolve symlinks explicitly (`realpath`) — an attacker who can upload can plant one inside the base directory.
  5. Apply the same check to every archive entry name before extraction, and to any path used for writing, deleting or including.
  6. Run the service with filesystem permissions that make an escape uninteresting.

Fixes we shipped

Each of these is a pull request Orbis AppSec opened against a real open-source repository.

clean_path() Path Traversal: rel_path Escapes USERDATA Dir

A path traversal flaw in the `clean_path()` helper let a user-controlled `rel_path` value escape the intended USERDATA directory and reach arbitrary files on disk. The function joined path segments without checking the final result, so `../` sequences in route parameters or query strings could be used to read or write files outside the sandboxed storage area. The fix normalizes the path and verifies it still resolves inside the USERDATA root before returning it, raising an error otherwise.

path.resolve() Path Traversal in Node CLI's Dynamic import()

A Node.js CLI script for validating expression definitions took a file path from `process.argv[2]`, resolved it with `path.resolve()`, and passed the result straight into a dynamic `import()` — with no check that the resolved path stayed inside the working directory. An attacker (or a malicious skill/plugin invocation) could supply traversal sequences to load and execute arbitrary `.js` files from anywhere on disk. The fix adds a boundary check with `path.relative()` and an extension allowlist b

bookDir() Path Traversal via Unsanitized bookId Parameter

The `bookDir()` function accepted unsanitized `bookId` values derived from user-created book titles, enabling path traversal attacks through `../` sequences. A fix was applied that validates the identifier using `path.basename()` and throws on mismatch, ensuring all resolved paths remain within `LIBRARY_DIR`.

Express `app.get('*')` Wildcard Handler Path Traversal in watch.js

A first-party Express server's wildcard route handler used `req.url.indexOf('font.woff2')` to gate access to a font file, allowing attackers to bypass the substring check with crafted paths. The fix replaces the catch-all handler with explicit route registration.

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

markitdown_bridge.py Path Traversal: Arbitrary File Read via sys.argv

The markitdown_bridge.py script, used by MDView for DOCX-to-Markdown conversion, accepted file paths directly from command-line arguments without validating they stayed within intended directories. An attacker could exploit this to read arbitrary files from the filesystem by passing path traversal sequences in the source_path parameter.

SHACL Viewer Path Traversal in graph3d(): Unvalidated `path`

The `graph3d()` and `graph2d()` request handlers in SHACL Viewer directly concatenated user-supplied `path` parameters into filesystem paths, enabling directory traversal outside the intended `/shapes/` directory. The fix introduces `_resolve_shapes_path()` with `os.path.realpath()` validation to enforce containment within the shapes directory.

fs.readFileSync(process.argv[2]) Path Traversal in Zola Build

A build-time helper that extracts the expected SHA-256 for a downloaded Zola release passed `process.argv[2]` straight into `fs.readFileSync()` with no directory constraint, so any caller able to influence that argument could make the integrity check read an arbitrary file. The fix resolves the requested path and requires it to be a direct child of the tools directory, which is now passed in as an extra argument, and exits with an error otherwise. Because the bytes read become the "expected" che

Browse every path traversal case study

Frequently asked questions

Why is stripping ../ from the input not enough?

Because the string you filter is not the string the filesystem sees. `%2e%2e%2f` becomes `../` after URL decoding, `....//` becomes `../` after a single naive strip, and `..\` works on Windows. Filtering is also blind to absolute paths, which do not contain `..` at all. Normalise and resolve first, then make one decision about the resolved result.

Is startsWith(baseDir) a sufficient containment check?

Not on its own. `/srv/uploads-evil/secret` starts with `/srv/uploads`, so the check passes for a sibling directory. Compare against `baseDir + separator`, or use a path-aware API — `Path.is_relative_to` in Python, `Path.startsWith` in Java, `path.relative` with a leading-`..` test in Node.

Do symlinks matter if the request cannot contain one?

Yes, whenever anything else can create files in the base directory — an upload endpoint, a shared volume, another service, or a previously extracted archive. `realpath` before the containment check closes it. On Linux, `openat` with `O_NOFOLLOW` (or `openat2` with `RESOLVE_BENEATH`) enforces it at the syscall level and avoids the time-of-check/time-of-use window entirely.

Is a chroot or container enough?

It bounds the damage, which is worth having, but the interesting files are often inside the container: the application's own configuration, mounted secrets, service-account tokens at `/var/run/secrets`, and `/proc/self/environ`. Containment in code and isolation at the boundary are complementary, not alternatives.

Let Orbis AppSec find these for you

Orbis AppSec scans your GitHub repositories, traces the taint from source to sink, and opens a pull request with the fix applied and verified.

Try Orbis AppSec

Authoritative sources

See also: File upload security fixes we shipped