Secure Coding: Best practices for using Java NIO against path traversal

Java's NIO package provides robust file and path name modification tools that developers can use to mitigate the risks of path traversal (CWE-22).

listen Print view
My thumbs up in front of a construction site

(Image: erstellt mit Chat-GPT / DALL-E)

14 min. read
By
  • Sven Ruppert
Contents

The article Secure Coding: Preventing unauthorized access through path traversal (CWE-22) has already described the dangers posed by the critical vulnerability CWE-22 (path traversal) in software development and what developers can do about it. The following section deals specifically with how the tools from Java's New I/O (NIO) package can be used to counter the risks associated with CWE-22. Best practices are used to show step-by-step how the robust NIO tools for file and path management can be used specifically to prevent path traversal vulnerabilities.

Secure Coding – Sven Ruppert
Sven Ruppert

Seit 1996 programmiert Sven Java in Industrieprojekten und seit über 15 Jahren weltweit in Branchen wie Automobil, Raumfahrt, Versicherungen, Banken, UN und Weltbank. Seit über 10 Jahren ist er von Amerika bis nach Neuseeland als Speaker auf Konferenzen und Community Events, arbeitete als Developer Advocate für JFrog und Vaadin und schreibt regelmäßig Beiträge für IT-Zeitschriften und Technologieportale. Neben seinem Hauptthema Core Java beschäftigt er sich mit TDD und Secure Coding Practices.

Normalization removes all redundant elements from a path, such as "." (current directory) and ".." (parent directory). This process helps to ensure that paths are appropriately structured and prevents path traversal attacks.

Create the base path: Define the base directory that will be used to resolve user input. This should be the directory to which you want to restrict access.

 Path basePath = Paths.get("/var/www/uploads").normalize();

Resolve the user input: Combine the base path with the user-supplied input to create a complete path. This step is critical to ensure that all relative paths provided by the user are interpreted in the context of the base path.

String userInput = request.getParameter("file");
Path resolvedPath = basePath.resolve(userInput);

Normalize the resolved path to remove all redundant elements. This step ensures that all "." or ".." in the path are resolved correctly.

Path normalizedPath = resolvedPath.normalize();

Validate the normalized path: Ensure that the normalized path starts with the base path. This check ensures that the path does not run outside the intended directory.

if (!normalizedPath.startsWith(basePath)) {
  throw new SecurityException("Invalid file path: path traversal attempt detected.");
   }

Check the path to make sure it stays within a base directory: Make sure the base directory is normalized to its simplest form. Combine the base directory with the user-supplied path and then normalize the resulting path. Specify that the final normalized path begins with the base directory to ensure that it does not go outside the intended directory.

Use secure directory and file permissions

Using secure directory and file permissions is important to ensure the security and integrity of your files and directories, especially when dealing with user input in Java applications.

Java NIO provides APIs for setting and checking file permissions. The PosixFilePermissions and PosixFileAttributeView classes can do this.

Setting permissions for a directory

To set permissions for a directory, you can use Files.createDirectories and PosixFilePermissions:

import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Set;
import java.io.IOException;

public class SecureFileHandler {

    /**
     * Creates a directory with secure permissions.
     *
     * @param dirPath The path of the directory to create.
     * @throws IOException if an I/O error occurs.
     */
    public static void createSecureDirectory(Path dirPath) throws IOException {
        Set<PosixFilePermission> perms = PosixFilePermissions.fromString("rwxr-x---");
        FileAttribute<Set<PosixFilePermission>> attr 
                = PosixFilePermissions.asFileAttribute(perms);
        Files.createDirectories(dirPath, attr);
    }

    /**
     * Example usage of creating a secure directory.
     *
     * @param args Command line arguments.
     */
    public static void main(String[] args) {
        Path dirPath = Paths.get("/var/www/uploads");
        try {
            createSecureDirectory(dirPath);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Setting permissions for files

You can also set permissions for files using the Files.setPosixFilePermissions method:

import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.io.IOException;
import java.util.Set;

public class SecureFileHandler {

    /**
     * Sets secure permissions for a file.
     *
     * @param filePath The path of the file.
     * @throws IOException if an I/O error occurs.
     */
    public static void setSecureFilePermissions(Path filePath) throws IOException {
        Set<PosixFilePermission> perms = PosixFilePermissions.fromString("rw-r-----");
        Files.setPosixFilePermissions(filePath, perms);
    }

    /**
     * Example usage of setting secure file permissions.
     *
     * @param args Command line arguments.
     */
    public static void main(String[] args) {
        Path filePath = Paths.get("/var/www/uploads/example.txt");
        try {
            setSecureFilePermissions(filePath);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Check file permissions

Before you carry out file operations, it is important to check whether the file or directory has the correct permissions. Here's how you can do this:

import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermissions;
import java.nio.file.attribute.PosixFilePermission;
import java.io.IOException;
import java.util.Set;

public class SecureFileHandler {

    /**
     * Checks if a file has the required permissions.
     *
     * @param filePath The path of the file.
     * @param requiredPerms The required permissions.
     * @return True if the file has the required permissions, false otherwise.
     * @throws IOException if an I/O error occurs.
     */
    public static boolean hasRequiredPermissions(Path filePath, 
                                                                                     Set<PosixFilePermission> requiredPerms) 
                throws IOException {
        Set<PosixFilePermission> perms = Files.getPosixFilePermissions(filePath);
        return perms.containsAll(requiredPerms);
    }

    /**
     * Example usage of checking file permissions.
     *
     * @param args Command line arguments.
     */
    public static void main(String[] args) {
        Path filePath = Paths.get("/var/www/uploads/example.txt");
        Set<PosixFilePermission> requiredPerms = PosixFilePermissions.fromString("rw-r-----");
        try {
            if (hasRequiredPermissions(filePath, requiredPerms)) {
                System.out.println("File has the required permissions.");
            } else {
                System.out.println("File does not have the required permissions.");
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Restrict write access: Make sure that write access is restricted to the required users and processes. This minimizes the risk of unauthorized changes.

Read access: Set read-only permissions for files that do not need to be changed to prevent unauthorized changes.

Execute permissions: Be careful when granting execute permissions. Only grant execute permissions to the required users and ensure that scripts or executable files are secure.

Owner and group permissions: Set the appropriate owner and group permissions. Ensure that confidential files and directories belong to the correct user and group.

Symbolic links: If possible, avoid following symbolic links. This is the only way to prevent attackers from bypassing security controls using symbolic link attacks.

Use of umask: Configure the umask value to control the default permission settings for newly created files and directories. This ensures basic security.

Using Java NIO's Path and Files classes and the correct permission settings can significantly improve the security of your file and directory operations. These practices help reduce the risk of unauthorized access and modification and protect your applications from potential CWE-22 (Path Traversal) vulnerabilities.

Handle symlinks securely

Secure handling of symbolic links (symlinks) is critical to prevent potential security risks such as access control bypassing and path traversal attacks. Attackers can exploit symlinks to gain unauthorized access to files and directories. The following best practices will help you to ensure the secure handling of symlinks in Java using the NIO API (New I/O):

Avoid following symlinks: Use the NOFOLLOW_LINKS option when performing file operations to avoid following symlinks. This ensures that operations are performed for the symlink and not for the target file or directory.

Validate the target of symlinks: If your application needs to follow symlinks, validate the target of the symlink to ensure that it points to an allowed location.

Search for symlinks: Explicitly check if a path is a symlink and treat it accordingly.

Example 1: How to avoid following symlinks in file operations with Java NIO:

import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.io.IOException;

public class SecureSymlinkHandler {

    /**
     * Checks if the given path is a symbolic link.
     *
     * @param path The path to check.
     * @return True if the path is a symbolic link, false otherwise.
     * @throws IOException if an I/O error occurs.
     */
    public static boolean isSymlink(Path path) throws IOException {
        return Files.isSymbolicLink(path);
    }

    /**
     * Safely deletes a file without following symbolic links.
     *
     * @param path The path to the file to delete.
     * @throws IOException if an I/O error occurs.
     */
    public static void safeDelete(Path path) throws IOException {
        if (isSymlink(path)) {
            throw new SecurityException("Refusing to delete symbolic link: " + path);
        }
        Files.delete(path);
    }

    /**
     * Safely reads a file's attributes without following symbolic links.
     *
     * @param path The path to the file.
     * @return The file's attributes.
     * @throws IOException if an I/O error occurs.
     */
    public static BasicFileAttributes safeReadAttributes(Path path) throws IOException {
        return Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
    }

    public static void main(String[] args) {
        Path path = Paths.get("/var/www/uploads/example.txt");
        try {
            if (isSymlink(path)) {
                System.out.println("Path is a symbolic link.");
            } else {
                System.out.println("Path is not a symbolic link.");
                BasicFileAttributes attrs = safeReadAttributes(path);
                System.out.println("File size: " + attrs.size());
                safeDelete(path);
                System.out.println("File deleted safely.");
            }
        } catch (IOException | SecurityException e) {
            e.printStackTrace();
        }
    }
}

Example 2: Validate the destination of symlinks. If your application needs to follow symlinks, validate their destinations to ensure that they point to an acceptable (safe) location:

import java.nio.file.*;
import java.io.IOException;

public class SecureSymlinkHandler {

    /**
     * Validates that the symlink's target is within the allowed base directory.
     *
     * @param symlink The symbolic link to validate.
     * @param baseDir The allowed base directory.
     * @throws IOException if an I/O error occurs or if validation fails.
     */
    public static void validateSymlinkTarget(Path symlink, Path baseDir) throws IOException {
        if (!Files.isSymbolicLink(symlink)) {
            throw new IllegalArgumentException("Path is not a symbolic link: " + symlink);
        }
        Path target = Files.readSymbolicLink(symlink).normalize();
        Path resolvedTarget = baseDir.resolve(target).normalize();

        if (!resolvedTarget.startsWith(baseDir)) {
            throw new SecurityException("Invalid symlink target: " + resolvedTarget);
        }
    }

    public static void main(String[] args) {
        Path symlink = Paths.get("/var/www/uploads/symlink");
        Path baseDir = Paths.get("/var/www/uploads").normalize();
        try {
            validateSymlinkTarget(symlink, baseDir);
            System.out.println("Symlink target is valid and within the allowed base directory.");
        } catch (IOException | SecurityException e) {
            e.printStackTrace();
        }
    }
}

Only allow trusted users to create symlinks. This minimizes the risk of symlinks being used for malicious purposes. Regularly check symbolic links in your application to ensure that they do not point to unauthorized locations. Use libraries that recognize symlinks and process them securely. This can help reduce the risk of unintentional symlink following. Ensure your application is running with the minimum required permissions to minimize the impact of potential symlink-related vulnerabilities. Use multiple layers of security controls to protect against symlink attacks. These include file system permissions, application-level checks and regular monitoring.

Rigorous validation of user input is critical to ensure the security and integrity of an application. Proper input validation helps prevent various attacks, including path traversal (CWE-22), SQL injection, cross-site scripting (XSS) and more. The following best practices and techniques will help you implement strict validation of user input in Java applications:

  • Whitelist validation: only allow input that matches a predefined set of acceptable values. This is the most secure form of validation.
  • Blacklist validation: Reject input that is known to contain dangerous characters or patterns. This approach is less secure than whitelisting, but can be used as an additional measure.
  • Length checks: Ensure that inputs are within the expected length limits. This prevents buffer overflows and denial of service (DoS) attacks.
  • Data type checks: Ensure that inputs match the expected data type (e.g. integers, dates).
  • Encoding and escaping: encode and mask inputs to prevent injection attacks in different contexts (e.g. HTML, SQL).
  • Canonicalization: Convert input into a standard format before validation. This helps to reliably compare and process input.
  • Restrict permitted characters: Validate file names and paths using a whitelist of permitted characters. Reject all entries that contain characters that can change the path structure (e.g. ..., /, \).
  • Check the path against the base directory: Make sure that the resolved and normalized path starts with the intended base directory.

Below is an example implementation in Java that combines these principles and techniques to validate user input, especially for file paths:

import java.nio.file.*;
import java.util.Set;
import java.util.HashSet;
import java.util.logging.Logger;
import java.io.IOException;
import java.util.regex.Pattern;

public class SecureInputValidator {

    private static final Logger logger = Logger.getLogger(SecureInputValidator.class.getName());
    private static final Set<String> ALLOWED_EXTENSIONS = new HashSet<>();

    static {
        ALLOWED_EXTENSIONS.add(".txt");
        ALLOWED_EXTENSIONS.add(".jpg");
        ALLOWED_EXTENSIONS.add(".png");
        ALLOWED_EXTENSIONS.add(".pdf");
    }

    /**
     * Validates the user-provided file name against a whitelist of allowed characters and extensions.
     *
     * @param fileName The user-provided file name.
     * @throws IllegalArgumentException if the file name is invalid.
     */
    public static void validateFileName(String fileName) throws IllegalArgumentException {
        if (fileName == null || fileName.isEmpty()) {
            throw new IllegalArgumentException("File name cannot be null or empty.");
        }

        // Check for invalid characters
        Pattern pattern = Pattern.compile("[^a-zA-Z0-9._-]");
        if (pattern.matcher(fileName).find()) {
            throw new IllegalArgumentException("File name contains invalid characters.");
        }

        // Check for allowed file extensions
        boolean validExtension = ALLOWED_EXTENSIONS.stream().anyMatch(fileName::endsWith);
        if (!validExtension) {
            throw new IllegalArgumentException("File extension is not allowed.");
        }
    }

    /**
     * Validates the user-provided path to ensure it stays within the base directory.
     *
     * @param baseDir The base directory.
     * @param userInput The user-provided input.
     * @return The validated and normalized path.
     * @throws SecurityException if a path traversal attempt is detected.
     * @throws IllegalArgumentException if the file name is invalid.
     * @throws IOException if an I/O error occurs.
     */
    public static Path getSecureFilePath(String baseDir, String userInput) throws SecurityException, IllegalArgumentException, IOException {
        validateFileName(userInput);

        // Normalize the base directory
        Path basePath = Paths.get(baseDir).normalize();

        // Resolve the user input against the base directory and normalize the result
        Path resolvedPath = basePath.resolve(userInput).normalize();

        // Validate that the resolved path starts with the base directory
        if (!resolvedPath.startsWith(basePath)) {
            logSuspiciousActivity(userInput);
            throw new SecurityException("Invalid file path: path traversal attempt detected.");
        }

        return resolvedPath;
    }

    /**
     * Logs suspicious activity for further analysis.
     *
     * @param userInput The suspicious user input.
     */
    private static void logSuspiciousActivity(String userInput) {
        logger.warning("Suspicious file access attempt: " + userInput);
    }

    /**
     * Example usage of the secure file path validation.
     *
     * @param args Command line arguments.
     */
    public static void main(String[] args) {
        String baseDir = "/var/www/uploads";
        String userInput = "example.txt";
        try {
            Path filePath = getSecureFilePath(baseDir, userInput);
            System.out.println("Validated file path: " + filePath);
        } catch (SecurityException | IllegalArgumentException | IOException e) {
            e.printStackTrace();
        }
    }
}

Logging and monitoring are essential to effectively prevent or remediate CWE-22 vulnerabilities. For example, these practices help you detect suspicious activity that could indicate a path traversal attempt. By collecting and analyzing logs, you can detect unusual patterns or attempts to access files and directories that should be off-limits.

The following example shows how you can detect anomalies, including attempts to use ../ sequences to access parent directories, by logging all file access attempts, including requested paths.

private static final Logger logger = Logger.getLogger(SecureFileHandler.class.getName());

 public static void logFileAccessAttempt(String filePath) {
  logger.info("File access attempt: " + filePath);
 }

Incident response and forensics: In the event of a security incident, detailed logs can be helpful in forensic analysis to understand how the attack was carried out. This information is important to close security gaps and prevent future attacks.

Example: Detailed logs can show the sequence of events that led to a detected breach, including the exact user inputs and system responses.

  try {
      Path filePath = resolveFilePath(userInput);
      logFileAccessAttempt(filePath.toString());
  } catch (SecurityException e) {
      logger.warning("Security exception: " + e.getMessage());
      throw e;
  }

Accountability and compliance: Many regulatory conditions and standards require logging and monitoring as part of their compliance requirements. By implementing these practices, you ensure that you are meeting legal and regulatory obligations.

Example: Compliance with standards such as PCI-DSS or GDPR often requires comprehensive logging of security-related events to ensure accountability.

Proactive threat detection: Continuous monitoring allows you to detect and proactively respond to threats before they cause significant damage. Real-time monitoring solutions can alert you to potential path traversal attacks as soon as they occur.

Example: Implementing real-time alerts for suspicious file access patterns can help you take immediate corrective action.

  // Example of setting up a real-time alert system (pseudo-code)
  if (detectedPathTraversalAttempt) {
      alertSecurityTeam("Potential path traversal attack detected: " + attemptedPath);
  }

Improving the security posture: Regularly analyzing logs and monitoring data helps to improve the overall security posture by identifying vulnerabilities and refining security policies.

For example, reviewing access logs can uncover common user errors or misconfigurations that could be exploited so you can proactively fix them.

Real-time monitoring: Use monitoring tools and services to track log data and generate alerts on suspicious activity. Tools such as the ELK stack (Elasticsearch, Logstash, Kibana), Splunk or cloud-based solutions such as AWS CloudWatch and Azure Monitor can be useful.

// Pseudo-code for setting up real-time monitoring and alerts
if (detectPathTraversal(logFileAccessAttempt)) {
    triggerAlert("Potential path traversal attack detected: " + logFileAccessAttempt);
}

Implementing logging and monitoring is not only a best practice, but a necessary part of a robust security strategy. It helps with early threat detection, regulatory compliance, effective incident response and overall security posture improvement. Ensuring that all file access attempts and related activities are logged and monitored helps to better protect your applications from CWE-22 and other vulnerabilities.

Using SecureRandom to create temporary files in Java adds security and randomness and reduces risks from vulnerabilities like CWE-22. Follow these best practices:

Instantiate SecureRandom: Create an instance of SecureRandom to generate random values for temporary file names or other purposes that require secure randomness.

SecureRandom secureRandom = new SecureRandom();

Generate secure random values: Use SecureRandom to generate a byte sequence that can be converted to a string or used directly in file names.

byte[] randomBytes = new byte[16];
secureRandom.nextBytes(randomBytes);
String randomString = new BigInteger(1, randomBytes).toString(16);

Create temporary files with secure and unique file names: When creating temporary files, make sure that the file names are unpredictable to avoid security risks such as file name collisions or targeted attacks. A best practice is to add a random string generated by a secure random number generator (such as SecureRandom) to the file name. This ensures that each file name is unique and difficult to predict, significantly reducing the risk of attacks such as file access theft or file name pattern exploitation.

The use of SecureRandom to generate the random character string also ensures that the generated values are cryptographically secure. The random values are therefore more difficult to predict and attackers cannot use them to exploit vulnerabilities in the file name. The secure creation of temporary files is an essential part of protection against attacks such as symlink or time-of-check-to-time-of-use (TOCTOU) attacks, in which attackers attempt to manipulate access to insecure temporary files.

Path tempDir = Paths.get(System.getProperty("java.io.tmpdir"));
Path tempFile = tempDir.resolve("tempFile_" + randomString + ".tmp");
Files.createFile(tempFile);

Set up file permissions: Set appropriate file permissions to prevent unauthorized users from accessing the temporary files.

Files.setPosixFilePermissions(tempFile, PosixFilePermissions.fromString("rw-------"));

Handle SecureRandom properly: Make sure that SecureRandom is not reset unnecessarily, as this can reduce the quality of randomness. Initialize it once and use the instance again.

SecureRandom secureRandom = new SecureRandom();
// Use secureRandom throughout the application

Use java.nio.file for file operations: Use the NIO package to benefit from its security features and avoid common pitfalls associated with older IO methods.

Path tempFile = Files.createTempFile(tempDir, "tempFile_", ".tmp");

By using FileSystems in Java for consistent path handling, you ensure that paths are managed in a compatible way across different platforms and file systems. The following best practices and techniques will help you do this:

Key concepts

  • FileSystems class: The java.nio.file.FileSystems class provides factory methods for creating file system instances and retrieving default file systems.
  • Path interface: The java.nio.file.Path interface represents a file or directory path in a platform-independent way.
  • Standardize path creation: Use FileSystems to create paths consistently.

Recommended procedure

Retrieve the default file system: The default file system corresponds to the file system of the platform on which the Java Virtual Machine is running.

import java.nio.file.FileSystem;
import java.nio.file.FileSystems;

FileSystem defaultFileSystem = FileSystems.getDefault();

Create paths using the file system: Create Path objects using the FileSystem to ensure that paths are created consistently.

import java.nio.file.Path;

Path path = defaultFileSystem.getPath("/var/www/uploads", "example.txt");

Treat paths consistently across platforms: Using FileSystems helps to create paths that are compatible with different operating systems.

Path windowsPath = defaultFileSystem.getPath("C:\\Users\\Public\\Documents", "example.txt");
Path unixPath = defaultFileSystem.getPath("/home/user/docs", "example.txt");

Paths for secure file operations: Using paths from file systems ensures that file operations are performed securely and consistently.

import java.nio.file.Files;
import java.io.IOException;

try {
    if (!Files.exists(path)) {
        Files.createFile(path);
    }
    // Perform file operations
} catch (IOException e) {
    e.printStackTrace();
}

Normalize and resolve paths: Always normalize and resolve paths to avoid problems with relative and symbolic links.

Path basePath = defaultFileSystem.getPath("/var/www/uploads").normalize();
Path userPath = basePath.resolve("example.txt").normalize();

if (!userPath.startsWith(basePath)) {
    throw new SecurityException("Path traversal attempt detected");
}

The following example demonstrates the use of FileSystems for consistent path handling:

import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermissions;
import java.io.IOException;

public class SecurePathHandler {

    private static final FileSystem fileSystem = FileSystems.getDefault();

    public static Path createSecureTempFile(String prefix, String suffix) throws IOException {
        Path tempDir = fileSystem.getPath(System.getProperty("java.io.tmpdir"));
        Path tempFile = Files.createTempFile(tempDir, prefix, suffix);
        Files.setPosixFilePermissions(tempFile, PosixFilePermissions.fromString("rw-------"));
        return tempFile;
    }

    public static Path resolveAndValidatePath(String baseDir, String userInput) throws SecurityException, IOException {
        Path basePath = fileSystem.getPath(baseDir).normalize();
        Path resolvedPath = basePath.resolve(userInput).normalize();

        if (!resolvedPath.startsWith(basePath)) {
            throw new SecurityException("Invalid file path: path traversal attempt detected.");
        }

        return resolvedPath;
    }

    public static void main(String[] args) {
        try {
            Path tempFile = createSecureTempFile("tempFile_", ".tmp");
            System.out.println("Temporary file created: " + tempFile);

            String baseDir = "/var/www/uploads";
            String userInput = "example.txt";
            Path filePath = resolveAndValidatePath(baseDir, userInput);
            System.out.println("Validated file path: " + filePath);

        } catch (IOException | SecurityException e) {
            e.printStackTrace();
        }
    }
}

Using FileSystems for consistent path handling in Java ensures that your application can handle paths in a platform-independent way. This helps to create secure, reliable and maintainable file management code. Following these best practices can prevent common pitfalls such as path traversal vulnerabilities and ensure that file operations are performed securely and consistently.

Happy Coding
Sven

(map)

Don't miss any news – follow us on Facebook, LinkedIn or Mastodon.

This article was originally published in German. It was translated with technical assistance and editorially reviewed before publication.