CWE-73: External Control of File Name or Path - Java
Overview
External control of file names or paths happens when untrusted input is used to construct a file system path and nothing confirms where that path lands. The input can arrive from HTTP requests, external APIs, message queues, databases, file uploads, or any source outside the application's control. Java's File, Path, and I/O classes offer minimal built-in protection against path traversal.
Primary Defence: Use Path.toRealPath() with path-component-aware startsWith() validation for existing targets, so canonicalized paths with symlinks resolved stay within the intended base directory. Allowlists for known file sets and UUID-based indirect reference maps for sensitive files reduce path traversal, absolute path injection, and symlink risks. For uploads, store under a server-generated name and reject any client-supplied name containing / or \ outright - do not rely on Paths.get(filename).getFileName() to strip components, because it resolves against the default FileSystem and leaves a backslash path intact on Linux. Spring's StringUtils.cleanPath() is display hygiene for a name you keep as metadata, not the containment boundary.
Common Vulnerable Patterns
Direct File Path from Untrusted Input
// VULNERABLE - No validation of untrusted path
@GetMapping("/download")
public ResponseEntity<Resource> downloadFile(@RequestParam String filename) {
File file = new File(filename);
FileInputStream fis = new FileInputStream(file);
// ...
}
// Attack example:
// GET /download?filename=../../../../etc/passwd
// Result: Reads /etc/passwd from the server
Why this is vulnerable: new File(filename) treats the parameter as the whole path, so there is no intended directory to escape - an absolute /etc/passwd works without any .. at all. @RequestParam performs no validation of its own; the binding is a type conversion, not a check. Whatever the JVM's user can read, this endpoint returns.
Validating Before the Final Decode
// VULNERABLE - the check runs on a value that is decoded again afterwards
@PostMapping("/upload")
public String uploadFile(@RequestParam String path, MultipartFile file) {
// Spring has already decoded the request parameter once
if (path.contains("..")) {
throw new SecurityException("Invalid path");
}
// A second decode restores the sequence the check rejected
String decoded = URLDecoder.decode(path, StandardCharsets.UTF_8);
File dest = new File("/uploads/" + decoded);
file.transferTo(dest);
return "Success";
}
// Attack example:
// POST /upload?path=%252e%252e%252f%252e%252e%252fetc%252fcron.d%252fbackdoor
// Spring yields "%2e%2e%2f%2e%2e%2fetc%2fcron.d%2fbackdoor" - no literal ".."
// URLDecoder yields "../../etc/cron.d/backdoor"
Why this is vulnerable: Spring decodes request parameters, so a singly-encoded ..%2F..%2F arrives as ../../ and this check would catch it. The denylist fails when something decodes a second time after validation, and it never sees the payloads that carry no .. at all - an absolute path, or a symbolic link inside the upload directory. Validate the path the filesystem will use: resolve it against the base directory, canonicalize with toRealPath(), and confirm containment with Path.startsWith().
Trusting Uploaded Filenames
// VULNERABLE - Using original filename without sanitization
@PostMapping("/upload")
public String handleFileUpload(@RequestParam("file") MultipartFile file) {
String filename = file.getOriginalFilename();
Path destinationFile = Paths.get("/uploads/").resolve(filename);
Files.copy(file.getInputStream(), destinationFile);
return "File uploaded";
}
// Attack example:
// Upload file with name: "../../../app/config/application.properties"
// Result: Overwrites application configuration
Why this is vulnerable: MultipartFile.getOriginalFilename() returns what the client put in the Content-Disposition header. That is request data, not a property of the file, and nothing in Spring sanitizes it - the javadoc says explicitly that the value should be treated as untrusted. Path.resolve() then behaves like Paths.get() on an absolute argument and discards the base entirely, so a client-supplied /etc/cron.d/backdoor needs no traversal sequence.
The direction matters here. A write that escapes the upload directory is not a disclosure, it is a foothold: overwriting application.properties changes the datasource on the next restart, and dropping a file into a directory the container serves can turn into execution.
Concatenating Paths Without Validation
// VULNERABLE - String concatenation allows traversal out of /data
@GetMapping("/file")
public byte[] getFile(@RequestParam String category,
@RequestParam String filename) {
String fullPath = "/data/" + category + "/" + filename;
return Files.readAllBytes(Paths.get(fullPath));
}
// Attack example:
// GET /file?category=reports&filename=../../etc/passwd
// Concatenation yields /data/reports/../../etc/passwd
// Result: Accesses /etc/passwd
Why this is vulnerable: Two request parameters are concatenated, so there are two injection points and the fix has to cover both - a validator written for filename alone leaves category open. Paths.get() does no resolution of its own; the .. segments survive into the string the OS opens, and the kernel walks them at open time.
Query parameters are the reachable shape here. Had these been @PathVariable values, the servlet container normalises ../ out of the request path before routing, so GET /file/../../etc/passwd would never bind those segments to the variables. Nothing normalises a query string, a form field or a JSON body, and Spring decodes percent-encoding before binding, so %2e%2e%2f arrives as ../. Non-HTTP sources - a database column, a queued job payload, a filename from an unpacked archive - reach the same concatenation with no normalisation anywhere in the path.
File Deletion Without Scope Validation
// VULNERABLE - Allows deletion of any file
@DeleteMapping("/file")
public String deleteFile(@RequestParam String path) {
File file = new File(path);
if (file.delete()) {
return "Deleted";
}
throw new RuntimeException("Failed to delete");
}
// Attack example:
// DELETE /file?path=/app/bin/application.jar
// Result: Deletes the application binary
Why this is vulnerable: There is no base directory in this method at all, so nothing is being escaped - the endpoint deletes whatever path it is handed, and the only limit is the JVM user's permissions. Deletion is worth separating from reads because the impact is immediate and irreversible: no data leaves the host, so egress monitoring sees nothing, and the response is a plain success either way.
Secure Patterns
Allowlist with Predefined Files (Most Secure)
public class SecureFileService {
private static final Path BASE_DIR = Paths.get("/app/data");
private static final Map<String, String> ALLOWED = Map.of(
"report", "report.pdf",
"summary", "summary.txt",
"data", "data.csv"
);
public byte[] downloadFile(String id) throws IOException {
String filename = ALLOWED.get(id);
if (filename == null) throw new SecurityException("File not allowed");
Path base = BASE_DIR.toRealPath(); // canonical base
Path candidate = base.resolve(filename).normalize();
// Component-wise containment (defense-in-depth)
if (!candidate.startsWith(base)) throw new SecurityException("Invalid path");
// Optional: detect symlink escapes (requires existence)
Path real = candidate.toRealPath();
if (!real.startsWith(base) || !Files.isRegularFile(real)) {
throw new SecurityException("Invalid file");
}
return Files.readAllBytes(real);
}
}
Why this works:
- User input selects a key from an exact allowlist of known-safe files rather than becoming a filesystem path.
- Traversal payloads (e.g.,
../, absolute paths, encodings) fail because they don't match an allowlist key. - With proper filesystem permissions (base directory not attacker-writable), this prevents external control of file paths.
Path Canonicalization with Boundary Check (Flexible and Secure)
// SECURE - Validates canonical path stays within allowed directory
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
public class SecurePathValidator {
private final Path baseDirectory;
public SecurePathValidator(String baseDir) throws IOException {
this.baseDirectory = Paths.get(baseDir).toRealPath();
}
public Path validatePath(String untrustedPath) throws IOException {
// Resolve and canonicalize the path
Path requestedPath = baseDirectory.resolve(untrustedPath).normalize();
// Convert to canonical path to resolve symlinks
Path canonicalPath = requestedPath.toRealPath();
// Verify it's within the base directory
if (!canonicalPath.startsWith(baseDirectory)) {
throw new SecurityException(
"Path traversal attempt detected: " + untrustedPath
);
}
return canonicalPath;
}
}
// Usage:
SecurePathValidator validator = new SecurePathValidator("/app/data");
Path safePath = validator.validatePath(untrustedInput);
byte[] content = Files.readAllBytes(safePath);
Why this works:
resolve(...).normalize()collapses./..so traversal sequences can't escape via path syntax.toRealPath()resolves symlinks and produces the real absolute target (for existing paths).startsWith(baseDirectory)enforces directory containment using path components (not string prefixes).- With appropriate filesystem permissions, this reduces traversal and common symlink-escape risk for reads.
Indirect Reference Map (UUID-based Access)
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.*;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public class SecureFileRegistry {
private static final long TTL_SECONDS = 300; // 5 min, example
private static class Entry {
final Path realPath;
final Instant expiresAt;
final String subject; // user/tenant id, optional
Entry(Path realPath, Instant expiresAt, String subject) {
this.realPath = realPath;
this.expiresAt = expiresAt;
this.subject = subject;
}
}
private final Map<UUID, Entry> registry = new ConcurrentHashMap<>();
private final Path baseReal;
public SecureFileRegistry(String baseDir) throws IOException {
this.baseReal = Paths.get(baseDir).toRealPath();
}
public UUID registerExistingFile(String internalPath, String subject) throws IOException {
Path candidate = baseReal.resolve(internalPath).normalize();
if (!candidate.startsWith(baseReal)) {
throw new SecurityException("Invalid path");
}
Path real = candidate.toRealPath(); // resolves symlinks, must exist
if (!real.startsWith(baseReal) || !Files.isRegularFile(real)) {
throw new SecurityException("Invalid file");
}
UUID token = UUID.randomUUID();
registry.put(token, new Entry(real, Instant.now().plusSeconds(TTL_SECONDS), subject));
return token;
}
public byte[] getFile(UUID token, String subject) throws IOException {
Entry e = registry.get(token);
if (e == null || Instant.now().isAfter(e.expiresAt) || (e.subject != null && !e.subject.equals(subject))) {
throw new FileNotFoundException("File not found");
}
return Files.readAllBytes(e.realPath);
}
}
Why this works:
- Users receive opaque tokens, not filesystem paths (reduces path traversal and path guessing).
- Tokens map to server-validated, canonical paths under a trusted base directory.
- Canonical/real-path validation rejects
../traversal and common symlink escapes at registration time. - Tokens should be scoped (per user/tenant) and short-lived, so a leaked one stops working when its TTL passes and is rejected if another user presents it.
Filename Sanitization with Extension Validation
// Sanitizes filename and validates extension
import java.util.Set;
public class SecureFilenameHandler {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
".pdf", ".txt", ".csv", ".xlsx"
);
public String sanitizeFilename(String filename) {
if (filename == null || filename.trim().isEmpty()) {
throw new IllegalArgumentException("Filename cannot be empty");
}
// Reject path syntax rather than stripping it. Paths.get() parses
// using the default FileSystem, so on Linux a backslash is an
// ordinary character and getFileName() returns the whole string.
if (filename.indexOf('/') >= 0 || filename.indexOf('\\') >= 0) {
throw new IllegalArgumentException(
"Filename must not contain path separators");
}
if (filename.equals(".") || filename.equals("..")) {
throw new IllegalArgumentException(
"Filename must not be a directory reference");
}
// Remove any remaining dangerous characters
String baseName = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
// Validate extension
String extension = getExtension(baseName);
if (!ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
throw new SecurityException("File type not allowed: " + extension);
}
return baseName;
}
private String getExtension(String filename) {
int lastDot = filename.lastIndexOf('.');
return (lastDot > 0) ? filename.substring(lastDot) : "";
}
}
Why this works (and what it doesn't do):
- Rejecting both separators explicitly is what makes this portable.
Paths.get(name).getFileName()resolves against the defaultFileSystem, so on LinuxPaths.get("..\\..\\etc\\passwd").getFileName()is the entire string - backslash is not a separator there. A name that is inert on the JVM's own filesystem can still be a path once it reaches a Windows client, an SMB share, or an archive writer. - The
./..rejection covers the directory references a separator check alone lets through. - Character allowlisting narrows the stored name to
[a-zA-Z0-9._-]. - Only the suffixes in
ALLOWED_EXTENSIONSare accepted. - This is input hygiene only: you must still enforce real-path containment and safe file handling when opening/writing files. In a Spring application, prefer not to reuse the client's name for the stored file at all -
StringUtils.cleanPath()normalises separators for a value you intend to log or display, and the Spring Boot section below stores under a server-generated identifier instead.
Framework-Specific Guidance
Spring Boot / Spring MVC
// SECURE - Spring Boot file upload with validation
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
@Service
public class SecureFileStorageService {
@Value("${file.upload-dir}")
private String uploadDir;
public String storeFile(MultipartFile file) throws IOException {
Path uploadBase = Paths.get(uploadDir).toAbsolutePath().normalize();
Files.createDirectories(uploadBase);
uploadBase = uploadBase.toRealPath();
// Keep original name only for display/logging
String original = StringUtils.cleanPath(
Objects.requireNonNullElse(file.getOriginalFilename(), "upload")
);
// Optional: extension allowlist based on original (policy)
// String ext = ...; validate ext ...
String stored = UUID.randomUUID().toString(); // + ext if you keep it
Path target = uploadBase.resolve(stored).normalize();
if (!target.startsWith(uploadBase)) {
throw new StorageException("Invalid upload path");
}
// Fail if exists (no overwrite)
try (InputStream in = file.getInputStream()) {
Files.copy(in, target);
}
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
throw new StorageException("Invalid upload target");
}
return stored;
}
}
// Controller usage:
@RestController
public class FileUploadController {
@Autowired
private SecureFileStorageService storageService;
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(
@RequestParam("file") MultipartFile file) {
try {
String filename = storageService.storeFile(file);
return ResponseEntity.ok("File uploaded: " + filename);
} catch (StorageException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}
// application.properties configuration:
// file.upload-dir=/var/app/uploads
Why this works:
- The upload directory is normalized and resolved to a real, canonical base path.
- The stored filename is a server-generated UUID, not a user-supplied path.
- The destination path is resolved under the canonical base and validated with
startsWith(). - The written file is verified to be a regular file rather than a symlink (
LinkOption.NOFOLLOW_LINKS). - Avoiding overwrites (and restricting who can write into the upload directory) reduces symlink and clobbering risks.
Apache Commons IO (Utility Library)
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import org.apache.commons.io.FileUtils; // optional convenience
public final class SecureFileHandler {
private final Path baseDirReal;
public SecureFileHandler(String baseDir) throws IOException {
// Canonicalize once: resolves symlinks + normalizes
this.baseDirReal = Paths.get(baseDir).toRealPath();
}
/** Validates an untrusted, user-supplied relative path and returns a safe, canonical path. */
public Path resolveSafe(String untrustedRelativePath) throws IOException {
if (untrustedRelativePath == null || untrustedRelativePath.isBlank()) {
throw new SecurityException("Missing path");
}
// Join as path components, then normalize (collapses "." and "..")
Path candidate = baseDirReal.resolve(untrustedRelativePath).normalize();
// Fast containment check on normalized path (blocks ../ traversal + absolute-path resolve)
if (!candidate.startsWith(baseDirReal)) {
throw new SecurityException("Path traversal detected");
}
// Resolve symlinks to detect symlink escapes (requires the target exists)
Path real = candidate.toRealPath();
// Final containment check on the real filesystem target
if (!real.startsWith(baseDirReal)) {
throw new SecurityException("Symlink escape detected");
}
// Only allow regular files (not directories/devices)
if (!Files.isRegularFile(real)) {
throw new SecurityException("Not a regular file");
}
return real;
}
/** Reads a UTF-8 text file securely. */
public String readUtf8(String untrustedRelativePath) throws IOException {
Path safe = resolveSafe(untrustedRelativePath);
// Plain NIO:
return Files.readString(safe, StandardCharsets.UTF_8);
// Or, if you prefer Commons IO:
// return FileUtils.readFileToString(safe.toFile(), StandardCharsets.UTF_8);
}
}
Why this works:
- Canonicalizes the base directory once (
toRealPath()), so checks use a trusted reference. - Resolves and normalizes user input under that base, then enforces containment with
startsWith(). - Resolves the target to a real path to detect symlink escapes, and re-checks containment.
- Restricts access to regular files only.
Java EE / Jakarta EE Servlet
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
import java.io.*;
import java.util.Set;
@WebServlet("/secure-download")
public class SecureFileDownloadServlet extends HttpServlet {
private static final String BASE = "/WEB-INF/files/";
private static final Set<String> ALLOWED = Set.of(
"public_report.pdf",
"user_guide.pdf"
);
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String filename = req.getParameter("file");
if (filename == null || !ALLOWED.contains(filename)) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "File access denied");
return;
}
String resourcePath = BASE + filename;
try (InputStream in = getServletContext().getResourceAsStream(resourcePath)) {
if (in == null) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
String mime = getServletContext().getMimeType(filename);
resp.setContentType(mime != null ? mime : "application/octet-stream");
resp.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
try (OutputStream out = resp.getOutputStream()) {
in.transferTo(out);
}
}
}
}
Why this works:
- Only a name on the allowlist reaches
resourcePath, so no attacker-controlled segment enters the path that gets opened. - The server serves only packaged resources under
/WEB-INF/files/(not directly web-accessible). - Files are streamed from the application context, avoiding
getRealPath()deployment pitfalls.
Common Pitfalls
- Calling
Paths.get(filename).getFileName()only to check the value against a denylist, then passing the original untrustedfilenamevariable toFiles.newInputStream()or similar - the sanitized copy that was checked and the value actually opened are two different variables. - Using
resolve(...).normalize()without a followingtoRealPath()-normalize()collapses./..syntactically but does not resolve symlinks, so a symlink placed inside the allowed directory that points elsewhere still passes astartsWith()check run on the normalized (not real) path. - Treating
Files.exists()orFiles.isRegularFile()as the authorization check - confirming a resolved path is a real, readable file does not confirm the requesting user is allowed to access it; without ownership/role checks, an attacker can still request another user's file if it happens to resolve within the base directory.