Skip to content

CWE-41: Improper Resolution of Path Equivalence - Java

Overview

Path equivalence vulnerabilities in Java occur when applications use java.io.File with string concatenation or comparison without canonicalization, allowing attackers to bypass access controls through symbolic links, . and .. sequences, or alternate path representations. Java's legacy File API does not resolve symbolic links, so a path built with it can point somewhere other than where the string suggests.

Primary Defence: Use java.nio.file.Path.toRealPath() to canonicalize existing user-supplied targets (resolving symbolic links, ., and .. sequences), validate filenames don't contain path separators (/ or \\), verify the canonical path starts with the allowed directory using path-component-aware startsWith(), and confirm it's a regular file with Files.isRegularFile() before access.

Common Vulnerable Patterns

// VULNERABLE - Symbolic Link Without Canonicalization
@GetMapping("/files/{filename}")
public ResponseEntity<Resource> getFile(@PathVariable String filename) {
    // No canonicalization - symbolic links can point outside allowed dir
    File file = new File("/app/uploads/" + filename);

    // Attacker creates: ln -s /etc/passwd /app/uploads/evil.txt
    // Access: /files/evil.txt → returns /etc/passwd

    if (file.exists()) {
        return ResponseEntity.ok(new FileSystemResource(file));
    }
    return ResponseEntity.notFound().build();
}

Why this is vulnerable:

  • Nothing canonicalizes the path, so a symbolic link inside /app/uploads is never resolved to the target it points at
  • The resulting path is never checked against the intended directory before it is read
  • Building the path by concatenating strings lets traversal sequences in filename walk out of /app/uploads
  • exists() answers whether the file is there, not whether it is inside the allowed directory

Secure Patterns

import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.Files;

@GetMapping("/files/{filename}")
public ResponseEntity<Resource> getFile(@PathVariable String filename) 
        throws IOException {

    // Validate filename doesn't contain path separators or traversal
    if (filename.contains("/") || filename.contains("\\") ||
        Paths.get(filename).getNameCount() != 1) {
        throw new BadRequestException("Invalid filename");
    }

    // Resolve allowed directory at request time to avoid startup failures
    Path allowedDir;
    try {
        allowedDir = Paths.get("/app/uploads").toRealPath();
    } catch (IOException e) {
        throw new IllegalStateException("Upload directory unavailable", e);
    }

    // Construct path
    Path requestedPath = allowedDir.resolve(filename).normalize();

    // Verify normalized path is within allowed directory before resolving symlinks
    if (!requestedPath.startsWith(allowedDir)) {
        throw new ForbiddenException("Access denied");
    }

    // Canonicalize (resolves symlinks, .., .)
    Path canonicalPath;
    try {
        canonicalPath = requestedPath.toRealPath();
    } catch (IOException e) {
        throw new NotFoundException("File not found");
    }

    // Verify canonical path is within allowed directory
    if (!canonicalPath.startsWith(allowedDir)) {
        throw new ForbiddenException("Access denied");
    }

    // Verify it's a regular file (not directory or special file)
    if (!Files.isRegularFile(canonicalPath)) {
        throw new NotFoundException("Not a file");
    }

    return ResponseEntity.ok(new FileSystemResource(canonicalPath.toFile()));
}

Why this works:

  • Resolves the allowed directory at request time to avoid startup failures when the directory is missing
  • Normalizes the requested path and checks it stays under the allowed directory before resolving symlinks
  • toRealPath() canonicalizes an existing path and resolves symlinks to their actual targets
  • Checks the canonical path against the allowed directory a second time, which is what catches a symlink pointing outside it
  • Rejects any filename that contains a path separator or resolves to more than one path component, so only a bare name reaches resolve()
  • Files.isRegularFile() rules out directories and special files
  • Both sides of the comparison are canonical, so two spellings of the same path compare equal; keep the base directory attacker-unwritable to avoid path-swap races

Additional Resources