Choose your stack

Node.js · Express

Fix path traversal in Node.js and Express

Check where the path lands, not what it looks like: resolve base with fs.realpath, resolve the request with realpath(path.resolve(base, name)), which collapses ../ and follows symlinks, and serve it only if it starts with base + path.sep.

Open the fix card
Python · Flask

Fix path traversal in Python and Flask

Resolve both paths and compare whole components: base = Path(base_dir).resolve(), target = (base / name).resolve(), and serve only if target.is_relative_to(base). resolve() collapses ../ and follows symlinks, and the check also catches absolute names, which replace base in the / operator.

Open the fix card
Java · Spring Boot

Fix path traversal in Java and Spring Boot

Compare real Path objects: base = baseDir.toRealPath(), target = base.resolve(name).toRealPath(), and serve only if target.startsWith(base). toRealPath() collapses ../ and follows symlinks, and Path.startsWith compares whole name elements, so /srv/files-secret fails.

Open the fix card
C# · ASP.NET Core

Fix path traversal in C# and ASP.NET Core

Use Path.GetFullPath(name, baseDir) and serve the result only if it starts with the base plus a trailing separator, compared with StringComparison.Ordinal.

Open the fix card
TypeScript · Next.js

Fix path traversal in TypeScript and Next.js

Check where the path lands, not what it looks like: resolve base with fs.realpath, resolve the request with realpath(path.resolve(base, name)), which collapses ../ and follows symlinks, and serve it only if it starts with base + path.sep.

Open the fix card
Python · FastAPI

Fix path traversal in Python and FastAPI

Resolve both paths and compare whole components before building the FileResponse: base = Path(base_dir).resolve(), target = (base / name).resolve(), and serve target only if target.is_relative_to(base). resolve() collapses ../ and follows symlinks, and the check also catches absolute names, which replace base in the / operator.

Open the fix card
Python · Django

Fix path traversal in Python and Django

Resolve both paths and compare whole components: base = Path(settings.FILES_ROOT).resolve(), target = (base / name).resolve(), and return FileResponse(target.open('rb')) only if target.is_relative_to(base). resolve() collapses ../ and follows symlinks; the check also catches absolute names.

Open the fix card
Go · net/http

Fix path traversal in Go and net/http

Open the base directory once with root, err := os.OpenRoot(base) and serve requested files through root.Open(name). os.Root (Go 1.24+) refuses names that leave the directory, whether by ../, an absolute path or a symlink, at the moment it opens the file. filepath.Join is not a check, and a strings.HasPrefix test on its result accepts files-secret and follows symlinks.

Open the fix card
Ruby · Rails

Fix path traversal in Ruby on Rails

Resolve the requested path with File.realpath(File.join(base, requested)), which collapses ../ and follows symlinks, and serve it only if it starts with the realpath of the base plus a separator.

Open the fix card
PHP · Laravel

Fix path traversal in PHP and Laravel

Resolve both paths and compare whole components: $base = realpath($root), $target = realpath($base.'/'.$name), and serve response()->file($target) only if $target starts with $base.DIRECTORY_SEPARATOR. realpath() collapses ../ and follows symlinks, and returns false for missing files.

Open the fix card

How it happens

  • The code joins request data onto a base directory (path.join, os.path.join or pathlib's / operator, Path.resolve, Path.Combine) and opens the result. Joining is not checking: ../ climbs out of the folder, and the operating system resolves it when the file is opened.
  • An absolute path needs no ../ at all. Python's os.path.join and pathlib, Java's Path.resolve and .NET's Path.Combine all drop the base when the second part is absolute; Node's path.join keeps it under the base, but path.resolve does not.
  • Frameworks decode input before your code sees it: %2e%2e%2f in a query value arrives as ../ in every stack here, and Express and Next.js also decode %2F inside route parameters. Filters that look at the raw URL miss both.
  • Hand-made fixes fail in predictable ways. Deleting ../ in one pass turns ....// back into ../, and startsWith('/srv/files') also accepts /srv/files-secret. Checks on the path string ignore symlinks: the built-in file serving of the four frameworks tested for it (express.static, Flask's send_from_directory, Spring's static resources and ASP.NET Core's PhysicalFileProvider) refused ../ but served a file through a symlink that pointed outside the folder. Starlette's StaticFiles, which FastAPI apps mount, refused that link with its default follow_symlink=False and served it with follow_symlink=True. Django's safe_join(), which django.views.static.serve and FileSystemStorage use, likewise refused ../ but returned the path through such a link.

Fix principles

  • Resolve, then check: turn the request into an absolute real path (fs.realpath, Path.resolve(), toRealPath()) and serve it only if it is inside the base directory, resolved the same way.
  • Compare whole path components. A string prefix must end with the separator; is_relative_to() and Path.startsWith on Path objects compare components for you.
  • Follow symlinks before you check, or refuse them. Where you cannot resolve every link on the path, as with .NET's ResolveLinkTarget, which resolves only the link at the path it is given, refuse any symlink between the base and the file.
  • Check the decoded value where it is used instead of filtering the input string. Removing ../ misses ....//, absolute paths and symlinks; rejecting any '..' still misses absolute paths and symlinks, and it refuses legitimate names such as v1.0..v2.0.diff.
  • Accept one non-empty value without NUL bytes, and answer the same 404 for refused and missing files so the response does not reveal what exists outside the folder.
  • When users only choose among known files, map an ID to a server-side path instead of accepting a path at all.
  • Keep the served tree read-only for untrusted users: a check followed by a read leaves a window in which a writer can swap in a symlink. On Linux 5.6 and later, openat2() with RESOLVE_BENEATH enforces containment when the file is opened.

How the cards prove it

  • Run each card's tests in three modes: the fixed implementation must pass, and the vulnerable and the plausible-but-wrong implementations must fail. run-all.mjs does this for every card.
  • The regression tests aim seven payloads at real files outside the base: ../, %2e%2e%2f, an absolute path, ..\, a path into the files-secret sibling, a symlink created in a temporary directory and ....//. All must answer 404; the ..\ case can only fail on Windows, where a backslash is a separator. Functional tests keep nested paths and a name with consecutive dots (v1.0..v2.0.diff) working.
  • Static analysis is uneven. Across the Express, Flask, Spring Boot, ASP.NET Core, Next.js and FastAPI cards, Semgrep 1.180.0 with the p/default, p/security-audit and p/owasp-top-ten packs (2026-10-10) found only one path problem: the C# rule's hit on the vulnerable store. The JavaScript rules miss node:-prefixed imports, the Java and Python rules miss Path.resolve() and pathlib reads, and nothing flagged the wrong fix. CodeQL's Python and C# models (read, not executed) and .NET's CA3003 do not recognise the fixed checks. Each card's scannerRules lists what was checked and what it found.

References

  1. OWASP: Path Traversal
  2. OWASP WSTG: Testing Directory Traversal File Include
  3. OWASP Top 10:2025 A01 Broken Access Control
  4. CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
  5. CWE-23: Relative Path Traversal
  6. CWE-36: Absolute Path Traversal
  7. CWE-59: Improper Link Resolution Before File Access ('Link Following')
  8. CAPEC-126: Path Traversal
  9. CAPEC-132: Symlink Attack
  10. PortSwigger Web Security Academy: Path traversal
  11. Linux man page: openat2(2) and RESOLVE_BENEATH

Attack patterns:

Practice in the browser

Guides on this weakness

Guide

Extract archives without escaping the destination

Prevent traversal, link tricks, overwrites and decompression exhaustion while preserving legitimate file extraction.

Guide

Convert untrusted office documents and PDFs in an isolated parser

DOCX, XLSX and ODF files are ZIP archives of XML, and PDFs can carry scripts; the converters that read them are large C and C++ programs with a long CVE history. Check the package before any parser reads it, convert each document in a container with no network, no writable root and hard limits, kill it at a deadline, and re-render to pixels when people will open the result.

Reference

Abuse case library: tests and controls by feature

Recurring abuse cases for sign-up and login, password reset, MFA enrolment, invites, file upload, payments and coupons, webhooks, search, export, admin and AI chat or agents: the attacker goal, preconditions, a concrete test and the control, each linked to CWE and CAPEC.

Guide

Prevent file inclusion: LFI, RFI, dynamic imports and template names

Stop request data from choosing which file, module or template your application loads: local and remote file inclusion in PHP, dynamic require() and import() in Node.js, importlib and template names in Python, Thymeleaf view names in Spring, render in Rails, and the allowlist fix that works in all of them.

Guide

Create temporary files safely: mkstemp, O_EXCL and private directories

Stop symlink, hardlink and pre-created-file attacks on the temporary files your code writes in shared directories such as /tmp: create each file atomically with O_EXCL and owner-only permissions through mkstemp or its equivalent in shell, Python, Java, Node.js, Go, .NET and Rust, keep related files in a private directory, and treat the Linux protected_* sysctls and systemd PrivateTmp as a backstop.