Skip to content

CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') - Java

Overview

Path Traversal (also known as Directory Traversal) occurs when an application uses user-supplied input to construct file paths without proper validation. Attackers can use special sequences like ../ or absolute paths to access files and directories outside the intended directory, potentially reading sensitive files (e.g., /etc/passwd, WEB-INF/web.xml) or overwriting critical system files.

Primary Defence: Use indirect reference mapping (map IDs to filenames). Where the path must be derived from input, resolve it against the base directory, canonicalize it with Path.toRealPath(), then confirm containment with Path.startsWith(). normalize() on its own collapses . and .. textually without touching the filesystem, so it will not detect a symbolic link inside the base directory that points outside it.

Common Vulnerable Patterns

Direct Path Concatenation

String filename = request.getParameter("file");
File file = new File("/uploads/" + filename); // VULNERABLE
FileInputStream fis = new FileInputStream(file);

Why this is vulnerable: Direct string concatenation allows attackers to use sequences like "../../etc/passwd" or "../../WEB-INF/web.xml" to traverse directories and read sensitive files outside the intended uploads directory.

String Formatting

String path = String.format("/data/%s", userInput); // VULNERABLE
Files.readAllBytes(Paths.get(path));

Why this is vulnerable: String.format() substitutes the input as-is, so "../" sequences and absolute paths like "/etc/passwd" survive into the path and take the read outside /data.

String filename = request.getParameter("file");
File file = new File("/uploads/" + filename); // VULNERABLE to symlink attacks
String absPath = file.getAbsolutePath();

// If /uploads/link is a symlink to /etc/passwd:
// Attack: ?file=link
// absPath is "/uploads/link" - the containment check below passes
if (!absPath.startsWith("/uploads/")) {
    throw new SecurityException("Invalid");
}
FileInputStream fis = new FileInputStream(file); // follows the link to /etc/passwd

Why this is vulnerable: getAbsolutePath() only prefixes the working directory when the path is relative. It never touches the filesystem, so it resolves neither .. nor symbolic links - the value it returns here is still /uploads/link, which satisfies a containment check while the open that follows resolves the link and reads /etc/passwd. An attacker who can create a symlink in the uploads directory, for example through a separate upload or archive-extraction feature, reads any file the process can open through a path that looks contained. Only a filesystem-resolving call reveals the real target: getCanonicalFile(), or Path.toRealPath() on the NIO API.

Validating Before the Final Decode

String filename = request.getParameter("file"); // container has already decoded once

// Simple check applied to an intermediate representation
if (filename.contains("..")) { // INSUFFICIENT
    throw new SecurityException("Invalid");
}

// A second decode re-introduces the sequence the check rejected
String decoded = URLDecoder.decode(filename, StandardCharsets.UTF_8);
File file = new File("/uploads/" + decoded);

// Attack: ?file=%252e%252e%252f%252e%252e%252fetc%252fpasswd
// getParameter returns "%2e%2e%2f%2e%2e%2fetc%2fpasswd" - no literal ".." to find
// After the second decode: "../../etc/passwd"
// VULNERABLE

Why this is vulnerable: The check and the filesystem see different strings. A servlet container decodes query parameters once, so request.getParameter() returns ../../etc/passwd for a singly-encoded payload and contains("..") would catch it - but any decode that happens after the check, whether an explicit URLDecoder.decode() call as here or one performed by a downstream component, restores the traversal. Denylists also miss the representations that contain no .. at all: an absolute path such as /etc/passwd, and symbolic links inside the allowed directory. Validate the path the filesystem will actually use, by resolving it with toRealPath() and confirming containment with Path.startsWith().

Secure Patterns

Indirect Reference (Best)

// Map user input to safe filenames
Map<String, String> fileMap = Map.of(
    "doc1", "user_manual.pdf",
    "doc2", "terms_of_service.pdf"
);

String fileId = request.getParameter("file");
String safeFilename = fileMap.get(fileId);
if (safeFilename == null) {
    throw new IllegalArgumentException("Invalid file");
}
File file = new File("/uploads/" + safeFilename); // SECURE - the name came from the allowlist, not the request

Why this works: Indirect reference mapping decouples user input from filesystem paths. Users provide keys (like "doc1") that map to pre-defined filenames, so no attacker-controlled string reaches the path and there is nowhere to inject ../ sequences or an absolute path.

Canonical Path Validation

String filename = request.getParameter("file");

Path base = Paths.get("/uploads").toRealPath(); // canonical base
Path candidate = base.resolve(filename).normalize();

// Boundary-safe check (component-wise)
if (!candidate.startsWith(base)) {
    throw new SecurityException("Path traversal detected");
}

// If you want symlink-escape protection, resolve real path of target (must exist)
Path real = candidate.toRealPath();                           // follows symlinks
if (!real.startsWith(base)) {
    throw new SecurityException("Symlink escape detected");
}

if (!Files.isRegularFile(real)) {
    throw new FileNotFoundException();
}

byte[] content = Files.readAllBytes(real);

Why this works:

  • Canonicalization resolves .., ., and symbolic links before the check, so traversal sequences are gone by the time the path is validated.
  • Containment is enforced after resolution, on the path the filesystem will actually open.
  • Path.startsWith() compares path components rather than characters, so a prefix bypass like /uploads_evil does not pass.
  • Resolving the real path catches a symlink inside the directory that redirects access outside it.
  • Only regular files are read, not directories or special filesystem objects.

Using Path API (Java 7+)

Path base = Paths.get("/uploads").toRealPath();
Path candidate = base.resolve(filename).normalize();

if (!candidate.startsWith(base)) {
    throw new SecurityException("Path traversal detected");
}

// Resolve symlinks in the target (requires existence)
Path real = candidate.toRealPath();
if (!real.startsWith(base)) {
    throw new SecurityException("Symlink escape detected");
}

if (!Files.isRegularFile(real)) {
    throw new FileNotFoundException();
}

byte[] content = Files.readAllBytes(real);

Why this works:

  • Normalization removes . and .. traversal sequences.
  • Path-based startsWith enforces directory containment safely.
  • Resolving the target to its real path detects symlink escapes.
  • Only validated, in-scope paths are accessed.

Framework-Specific Guidance

Spring Boot File Upload

@PostMapping("/upload")
public String handleFileUpload(@RequestParam("file") MultipartFile file) throws IOException {
    if (file.isEmpty()) throw new IllegalArgumentException("Empty upload");

    Path uploadDir = Paths.get("uploads").toAbsolutePath().normalize();
    Files.createDirectories(uploadDir);

    // Keep original name only for display/logging; don't use it for storage
    String original = file.getOriginalFilename();
    String ext = ""; // optionally derive/validate extension from original
    String storedName = UUID.randomUUID().toString() + ext;

    Path dest = uploadDir.resolve(storedName).normalize();
    if (!dest.startsWith(uploadDir)) throw new SecurityException("Invalid path");

    // Create new file and write to it; fail if exists
    try (InputStream in = file.getInputStream()) {
        Files.copy(in, dest); // no REPLACE_EXISTING
    }

    if (!Files.isRegularFile(dest, LinkOption.NOFOLLOW_LINKS)) {
        throw new SecurityException("Invalid upload target");
    }

    return "uploaded";
}

Why this works:

  • Directory components are stripped or ignored from user input.
  • Files are stored using server-generated names, not user-supplied paths.
  • The destination path is resolved and validated to stay within the upload directory.
  • Writes occur only to validated, regular files (no symlinks or special files).
  • Files.copy is called without REPLACE_EXISTING, so an upload that collides with an existing file fails instead of overwriting it.

Servlet File Download

@WebServlet("/download")
public class DownloadServlet extends HttpServlet {
    private static final Path BASE_DIR = Paths.get("/var/uploads");

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String id = request.getParameter("id");
        if (id == null || !id.matches("[a-zA-Z0-9_-]+")) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        Path base = BASE_DIR.toRealPath(); // canonical base
        Path candidate = base.resolve(id + ".pdf").normalize();

        if (!candidate.startsWith(base)) { // path-component safe
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        if (!Files.exists(candidate, LinkOption.NOFOLLOW_LINKS)) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        Path real = candidate.toRealPath(); // detect symlink escape
        if (!real.startsWith(base) || !Files.isRegularFile(real)) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        response.setContentType("application/pdf");
        response.setHeader("Content-Disposition", "attachment; filename=\"" + id + ".pdf\"");

        try (ServletOutputStream out = response.getOutputStream()) {
            Files.copy(real, out);
        }
    }
}

Why this works:

  • An allowlist restricts the ID to safe characters only.
  • The resolved path is validated (component-wise) to stay under the upload directory.
  • The target is resolved to its real path to detect symlink escapes.
  • Only regular files are served.

Input Validation Patterns

Filename Sanitization

public String sanitizeFilename(String filename) {
    if (filename == null) {
        throw new IllegalArgumentException("Filename is null");
    }

    // Get just the filename, strip any directory components
    Path path = Paths.get(filename);
    String safeName = path.getFileName().toString();

    // Reject if still contains problematic characters
    if (safeName.contains("..") || safeName.contains("/") ||
        safeName.contains("\\")) {
        throw new SecurityException("Invalid filename");
    }

    // Optional: allowlist allowed characters
    if (!safeName.matches("[a-zA-Z0-9._-]+")) {
        throw new SecurityException("Filename contains invalid characters");
    }

    return safeName;
}

Extension Validation

public void validateFileExtension(String filename, Set<String> allowedExtensions) {
    String extension = "";
    int lastDot = filename.lastIndexOf('.');
    if (lastDot > 0) {
        extension = filename.substring(lastDot + 1).toLowerCase();
    }

    if (!allowedExtensions.contains(extension)) {
        throw new SecurityException("File type not allowed");
    }
}

// Usage
Set<String> allowed = Set.of("pdf", "png", "jpg", "jpeg");
validateFileExtension(filename, allowed);

Common Pitfalls

  • Checking canonicalPath.startsWith(baseDir) as a plain string comparison instead of Path.startsWith(Path), and without a trailing separator on baseDir - /app/uploads-backup shares the /app/uploads prefix as text even though it is not contained within it; use the Path-based startsWith() shown above, which compares path components rather than characters.
  • Calling Paths.get(base, filename).normalize() without a subsequent toRealPath() - normalize() only collapses . and .. syntactically, so a symlink placed inside the allowed directory that points outside it still passes a boundary check performed on the normalized (but not resolved) path.
  • Allowlisting only the file extension (filename.endsWith(".pdf")) while still using attacker-controlled directory components in the same string - the extension check never inspects the path portion, so a traversal path that ends in an allowed extension still passes.

Migration Considerations

  1. Identify file operations: Search for File, FileInputStream, Files.read*, Paths.get
  2. Trace user input: Find where filename/path parameters come from
  3. Implement indirect references: Map IDs to filenames where possible
  4. Add canonical path validation: For remaining direct references
  5. Sanitize filenames: Remove directory components
  6. Test with payloads: ../, absolute paths, encoded traversal

Additional Resources