CWE-73: External Control of File Name or Path - Java
Overview
External control of file names or paths is a critical vulnerability in Java applications that occurs when untrusted input is used to construct file system paths without proper validation. Untrusted input can originate 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 provide powerful file manipulation capabilities, but they offer minimal built-in protection against path traversal attacks.
Primary Defence: Use Path.toRealPath() with startsWith() validation to ensure canonicalized paths (with symlinks resolved) stay within the intended base directory, implement allowlists for known file sets, use UUID-based indirect reference maps for sensitive file access, and sanitize uploaded filenames with Paths.get(filename).getFileName() combined with extension validation to prevent path traversal, absolute path injection, and symlink attacks.
Remediation Steps
Locate the Finding
Identify the Source (Untrusted Input)
- Look for where untrusted data enters the application:
- HTTP parameters:
request.getParameter("filename"),@RequestParam,@PathVariable - Servlet parameters:
HttpServletRequest.getParameter() - File uploads:
MultipartFile.getOriginalFilename() - Headers:
request.getHeader("X-File-Path") - JSON/XML input: Deserialized objects with file path fields
- Form beans: Properties bound from request parameters
- HTTP parameters:
Identify the Sink (File Operation)
- Trace to where the data reaches file system operations:
new File(userInput),new FileInputStream(path)Files.readAllBytes(),Files.write(),Files.copy()Paths.get(),Path.resolve()FileReader,FileWriter,BufferedReaderconstructorsRandomAccessFileoperations
Example from Security Scan Finding:
// Source: Untrusted filename from request parameter
@GetMapping("/download")
public ResponseEntity<byte[]> download(@RequestParam String file) { // ← SOURCE
// Sink: File operation using untrusted input
byte[] content = Files.readAllBytes(Paths.get(file)); // ← SINK
return ResponseEntity.ok(content);
}
Understand the Data Flow
Review the data flow in the security finding:
- Entry Point: Where untrusted input enters (controller method, servlet, API call)
- Intermediate Processing: Any transformations or validations (often insufficient)
- File Operation: The vulnerable sink where file access occurs
Key Questions:
- Is there any validation between source and sink?
- Are there string operations that might be bypassable? (
.contains(".."),.replace()) - Does the path go through
Paths.get()orPath.resolve()without validation? - Is there any canonicalization with
File.getCanonicalPath()orPath.toRealPath()? - Are legacy
java.io.FileAPIs used instead of modernjava.nio.file.Path?
Common Data Flow Pattern:
@PostMapping("/upload") // ← Entry point
public String upload(@RequestParam String path, // ← Source
MultipartFile file) {
// Insufficient validation (bypassable)
if (path.contains("..")) {
throw new SecurityException("Invalid path");
}
File dest = new File("/uploads/" + path); // ← Transformation
file.transferTo(dest); // ← Sink
return "Success";
}
Identify the Pattern
Match the code to vulnerable patterns:
- Absolute path injection → Pattern:
new File("/data/" + userInput)where userInput =/etc/passwd - Directory traversal → Pattern:
Paths.get("/uploads").resolve("../../etc/passwd") - Unsafe uploaded filename → Pattern:
file.transferTo(new File(file.getOriginalFilename())) - Legacy File API without validation → Pattern:
new File("/data/" + category + "/" + filename) - Denylist bypass → Pattern:
if (path.contains(".."))(bypassable with URL encoding:%2e%2e%2f) - Symlink following → Pattern: Not using
toRealPath()before validation
Verify the Fix
Manual testing with attack payloads:
# Test these path traversal attacks manually:
curl "http://localhost:8080/download?file=../../../../etc/passwd"
curl "http://localhost:8080/download?file=..\\..\\..\\Windows\\win.ini"
curl "http://localhost:8080/download?file=/etc/passwd"
curl "http://localhost:8080/download?file=C:\\Windows\\System32\\config\\SAM"
curl "http://localhost:8080/download?file=..%2f..%2fetc%2fpasswd" # URL-encoded
curl "http://localhost:8080/download?file=....//....//etc/passwd" # Double-dot
curl "http://localhost:8080/download?file=file.txt%00.jpg" # Null byte
# All should return:
# - 400 Bad Request, OR
# - 404 Not Found, OR
# - 403 Forbidden
# NEVER the content of /etc/passwd or Windows system files
Verification checklist:
-
Verify allowlist is enforced: Only approved base directories are accessible
-
Check canonical path validation: Ensure
getCanonicalPath()is used -
Test path normalization: Verify
..and similar patterns are blocked// Manual test in development: String[] attacks = { "../../../../etc/passwd", "..\\\\..\\\\..\\\\Windows\\\\win.ini", "....//....//etc/passwd" }; for (String attack : attacks) { try { Path result = validator.validatePath(attack); System.err.println("VULNERABILITY: Accepted path: " + attack); } catch (SecurityException | IllegalArgumentException e) { System.out.println("✓ Blocked: " + attack); } } -
Static analysis: Use SAST tools to detect path traversal
-
DAST testing: Use dynamic scanners (OWASP ZAP, Burp Suite) to test file endpoints with path traversal payloads
-
Verify legitimate access: Confirm allowed files and operations still work correctly
Check for Similar Issues
Search your codebase for other vulnerable patterns:
IntelliJ IDEA / Eclipse Search Patterns:
new File\(
new FileInputStream\(
new FileOutputStream\(
Files\.read
Files\.write
Paths\.get\(
Path\.resolve\(
MultipartFile\.getOriginalFilename\(\)
request\.getParameter.*file
request\.getParameter.*path
@RequestParam.*file
@PathVariable.*file
Common locations to check:
- All Spring
@Controlleror@RestControllermethods with file parameters - Servlet
doGet()/doPost()methods handling file operations - File upload handlers
- Document download endpoints
- Static resource serving configuration
- Report generation that creates files
- Backup/restore functionality
- Log file access features
- Template or configuration file handling
Review code patterns like:
// Pattern 1: Direct file parameter usage
public byte[] download(String file) {
return Files.readAllBytes(Paths.get(file));
}
// Pattern 2: String concatenation for paths
String path = baseDir + "/" + untrustedInput;
// Pattern 3: Insufficient validation
if (filename.contains("..")) { /* reject */ }
// Pattern 4: Using original upload filename
file.transferTo(new File(file.getOriginalFilename()));
// Pattern 5: Legacy File API
File f = new File(userSuppliedPath);
Spring Boot specific areas:
ResourceHttpRequestHandlerconfiguration- Custom file serving controllers
@Valueinjected file paths from properties- Thymeleaf template resolution with untrusted input
- Static resource locations in
WebMvcConfigurer
Jakarta EE / Java EE specific areas:
- Servlet filters handling file requests
- JSP/JSF file upload components
- EJB methods with file path parameters
- JAX-RS endpoints with
@PathParamfile references
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
Insufficient Denylist Validation
// VULNERABLE - Denylist can be bypassed
@PostMapping("/upload")
public String uploadFile(@RequestParam String path, MultipartFile file) {
// Incomplete validation
if (path.contains("..")) {
throw new SecurityException("Invalid path");
}
File dest = new File("/uploads/" + path);
file.transferTo(dest);
return "Success";
}
// Attack example:
// POST /upload?path=..%2F..%2Fetc%2Fcron.d%2Fbackdoor
// Result: URL-encoded ".." bypasses the check
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
Concatenating Paths Without Validation
// VULNERABLE - String concatenation allows absolute paths
@GetMapping("/file/{category}/{filename}")
public byte[] getFile(@PathVariable String category,
@PathVariable String filename) {
String fullPath = "/data/" + category + "/" + filename;
return Files.readAllBytes(Paths.get(fullPath));
}
// Attack example:
// GET /file/../../etc/passwd
// Result: Accesses /etc/passwd
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
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:
- Access is restricted to an exact allowlist of known-safe files.
- User input selects from the allowlist rather than becoming a filesystem path.
- Traversal payloads (e.g.,
../, absolute paths, encodings) fail because they don’t match allowed values. - 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 prevents traversal and common symlink-escape attacks 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 prevents
../traversal and common symlink escapes at registration time. - Tokens should be scoped (per user/tenant) and short-lived to prevent token reuse or leakage.
Filename Sanitization with Extension Validation
// Sanitizes filename and validates extension
import java.util.Set;
import java.nio.file.Path;
import java.nio.file.Paths;
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");
}
// Remove path components
String baseName = Paths.get(filename).getFileName().toString();
// Remove any remaining dangerous characters
baseName = baseName.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):
getFileName()drops any directory components so the result is a simple filename.- Character allowlisting reduces problematic characters for storage and logging.
- Extension allowlisting restricts which filename suffixes are accepted.
- This is input hygiene only: you must still enforce real-path containment and safe file handling when opening/writing files.
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 server-controlled (or strictly sanitized), not a user-supplied path.
- The destination path is resolved under the canonical base and validated with
startsWith(). - Files are written without implicit path escapes, and the result is verified as a regular file.
- 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:
- Access is restricted to an exact allowlist of approved filenames.
- The server serves only packaged resources under
/WEB-INF/files/(not directly web-accessible). - No user input is used to construct a filesystem path (prevents traversal by design).
- Files are streamed from the application context, avoiding
getRealPath()deployment pitfalls.
Verification
After implementing the recommended secure patterns, verify the fix through multiple approaches:
- Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
- Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
- Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
- Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
- Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
- Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
- Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
- Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced