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
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:
- 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
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 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
- CWE-35: Path Equivalence
- Java Path Class Documentation - toRealPath(), resolve(), startsWith()
- Java Files Class Documentation - isRegularFile(), exists()
- OWASP Path Traversal