Skip to content

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

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, BufferedReader constructors
    • RandomAccessFile operations

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:

  1. Entry Point: Where untrusted input enters (controller method, servlet, API call)
  2. Intermediate Processing: Any transformations or validations (often insufficient)
  3. 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() or Path.resolve() without validation?
  • Is there any canonicalization with File.getCanonicalPath() or Path.toRealPath()?
  • Are legacy java.io.File APIs used instead of modern java.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:

  1. Verify allowlist is enforced: Only approved base directories are accessible

    # Try to access files outside allowed directory
    curl "http://localhost:8080/download?file=/etc/passwd"
    # Should fail with 403 or 400
    
  2. Check canonical path validation: Ensure getCanonicalPath() is used

    grep -r "getCanonicalPath\|toRealPath" src/
    # Should be used before file access
    
  3. 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);
        }
    }
    
  4. Static analysis: Use SAST tools to detect path traversal

    # CodeQL: java/path-injection
    # SonarQube: S2083 (Path traversal)
    # Semgrep: java.lang.security.audit.path-traversal
    
    mvn sonar:sonar
    semgrep --config=p/path-traversal src/
    
  5. DAST testing: Use dynamic scanners (OWASP ZAP, Burp Suite) to test file endpoints with path traversal payloads

  6. 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 @Controller or @RestController methods 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:

  • ResourceHttpRequestHandler configuration
  • Custom file serving controllers
  • @Value injected 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 @PathParam file 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

Additional Resources