Skip to content

CWE-35: 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 is particularly vulnerable as it doesn't resolve symbolic links by default.

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

Common Vulnerable Patterns

@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:

  • No canonicalization means symbolic links pointing outside allowed directory aren't resolved
  • Path is not verified to be within intended directory before use
  • String concatenation for path construction allows directory traversal
  • exists() check doesn't prevent access to files outside allowed directory via symlinks

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 path and resolves all symlinks to their actual targets
  • Verifies canonical path starts with allowed directory, preventing symlink escapes
  • Rejects filenames containing path separators or traversal to prevent directory traversal
  • Checks file type to prevent directory listing or special file access
  • Both paths are canonical before comparison, eliminating equivalence issues

Additional Resources