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
Symbolic Link Without Canonicalization
// 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/uploadsis 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
filenamewalk out of/app/uploads exists()answers whether the file is there, not whether it is inside the allowed directory
Secure Patterns
Symlink Resolution with Real Path Verification
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
- CWE-41: Improper Resolution of Path Equivalence
- Java Path Class Documentation - toRealPath(), resolve(), startsWith()
- Java Files Class Documentation - isRegularFile(), exists()
- OWASP Path Traversal