CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('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). Where the path must be derived from input, resolve it against the base directory, canonicalize it with Path.toRealPath(), then confirm containment with Path.startsWith(). normalize() on its own collapses . and .. textually without touching the filesystem, so it will not detect a symbolic link inside the base directory that points outside it.
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() substitutes the input as-is, so "../" sequences and absolute paths like "/etc/passwd" survive into the path and take the read outside /data.
Symbolic Link Attack
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 is "/uploads/link" - the containment check below passes
if (!absPath.startsWith("/uploads/")) {
throw new SecurityException("Invalid");
}
FileInputStream fis = new FileInputStream(file); // follows the link to /etc/passwd
Why this is vulnerable: getAbsolutePath() only prefixes the working directory when the path is relative. It never touches the filesystem, so it resolves neither .. nor symbolic links - the value it returns here is still /uploads/link, which satisfies a containment check while the open that follows resolves the link and reads /etc/passwd. An attacker who can create a symlink in the uploads directory, for example through a separate upload or archive-extraction feature, reads any file the process can open through a path that looks contained. Only a filesystem-resolving call reveals the real target: getCanonicalFile(), or Path.toRealPath() on the NIO API.
Validating Before the Final Decode
String filename = request.getParameter("file"); // container has already decoded once
// Simple check applied to an intermediate representation
if (filename.contains("..")) { // INSUFFICIENT
throw new SecurityException("Invalid");
}
// A second decode re-introduces the sequence the check rejected
String decoded = URLDecoder.decode(filename, StandardCharsets.UTF_8);
File file = new File("/uploads/" + decoded);
// Attack: ?file=%252e%252e%252f%252e%252e%252fetc%252fpasswd
// getParameter returns "%2e%2e%2f%2e%2e%2fetc%2fpasswd" - no literal ".." to find
// After the second decode: "../../etc/passwd"
// VULNERABLE
Why this is vulnerable: The check and the filesystem see different strings. A servlet container decodes query parameters once, so request.getParameter() returns ../../etc/passwd for a singly-encoded payload and contains("..") would catch it - but any decode that happens after the check, whether an explicit URLDecoder.decode() call as here or one performed by a downstream component, restores the traversal. Denylists also miss the representations that contain no .. at all: an absolute path such as /etc/passwd, and symbolic links inside the allowed directory. Validate the path the filesystem will actually use, by resolving it with toRealPath() and confirming containment with Path.startsWith().
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); // SECURE - the name came from the allowlist, not the request
Why this works: Indirect reference mapping decouples user input from filesystem paths. Users provide keys (like "doc1") that map to pre-defined filenames, so no attacker-controlled string reaches the path and there is nowhere to inject ../ sequences or an absolute path.
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:
- Canonicalization resolves
..,., and symbolic links before the check, so traversal sequences are gone by the time the path is validated. - Containment is enforced after resolution, on the path the filesystem will actually open.
Path.startsWith()compares path components rather than characters, so a prefix bypass like/uploads_evildoes not pass.- Resolving the real path catches a symlink inside the directory that redirects access outside it.
- Only regular files are read, not 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
startsWithenforces 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).
Files.copyis called without REPLACE_EXISTING, so an upload that collides with an existing file fails instead of overwriting it.
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);
Common Pitfalls
- Checking
canonicalPath.startsWith(baseDir)as a plain string comparison instead ofPath.startsWith(Path), and without a trailing separator onbaseDir-/app/uploads-backupshares the/app/uploadsprefix as text even though it is not contained within it; use thePath-basedstartsWith()shown above, which compares path components rather than characters. - Calling
Paths.get(base, filename).normalize()without a subsequenttoRealPath()-normalize()only collapses.and..syntactically, so a symlink placed inside the allowed directory that points outside it still passes a boundary check performed on the normalized (but not resolved) path. - Allowlisting only the file extension (
filename.endsWith(".pdf")) while still using attacker-controlled directory components in the same string - the extension check never inspects the path portion, so a traversal path that ends in an allowed extension still passes.
Migration Considerations
- Identify file operations: Search for
File,FileInputStream,Files.read*,Paths.get - Trace user input: Find where filename/path parameters come from
- Implement indirect references: Map IDs to filenames where possible
- Add canonical path validation: For remaining direct references
- Sanitize filenames: Remove directory components
- Test with payloads:
../, absolute paths, encoded traversal