Skip to content

CWE-22: 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), or validate with Path.normalize() combined with Path.startsWith() to ensure the resolved path remains within the intended directory.

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() doesn't prevent path traversal sequences like "../" or absolute paths like "/etc/passwd", allowing attackers to escape the intended directory and access arbitrary files on the filesystem.

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 becomes "/etc/passwd" - VULNERABLE

Why this is vulnerable: Using getAbsolutePath() instead of getCanonicalFile() means symbolic links are not resolved to their actual targets. An attacker can create a symlink in the uploads directory pointing to sensitive files (/etc/passwd, /etc/shadow), then request the symlink name to read the target file. The application sees the path as within /uploads/ but actually reads from the symlink's target location.

URL-Encoded Path Traversal Bypass

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

// Simple check that can be bypassed
if (filename.contains("..")) { // INSUFFICIENT
    throw new SecurityException("Invalid");
}

File file = new File("/uploads/" + filename);

// Attack: ?file=..%2F..%2Fetc%2Fpasswd
// After URL decoding: ?file=../../etc/passwd
// Simple check sees "..%2F" (not ".."), allows it through
// VULNERABLE

Why this is vulnerable: Simple string checks for ".." fail when attackers use URL encoding (%2e%2e%2f for ../), double encoding (%252e%252e%252f), or mixed encoding. Servlet containers automatically decode URLs, so ..%2F becomes ../ after decoding but before the validation check. The only safe approach is canonical path validation using getCanonicalFile() which resolves all path components after decoding.

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); // SAFE

Why this works: Indirect reference mapping completely eliminates path traversal by decoupling user input from filesystem paths. Users provide keys (like "doc1") that map to pre-defined filenames - there's no way to inject ../ sequences or absolute paths because the actual filename is never derived from user input.

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:

  • Canonicalizes paths before validation
    Resolves .., ., and symbolic links so traversal tricks are removed.
  • Enforces directory containment after resolution
    Verifies the final resolved path is actually inside the allowed base directory.
  • Uses path-boundary checks, not string matching
    Prevents prefix bypasses like /uploads_evil.
  • Prevents symlink escapes
    Ensures symlinks inside the directory can’t redirect access outside it.
  • Limits access to regular files only
    Avoids serving 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).
  • Upload behavior is predictable (no implicit overwrites or path escapes).

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);

Migration Strategy

  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

Security Checklist

  • Use indirect references (ID → filename mapping)
  • If direct references needed, use getCanonicalFile() or toRealPath()
  • Validate resolved path is within allowed directory
  • Sanitize filenames - strip directory separators
  • Allowlist allowed file extensions
  • Validate with regex pattern for allowed characters
  • Never use user input directly in file paths
  • Test with traversal payloads

Additional Resources