FlawOpen

flawopen.com/path-traversal/Java

● CWE-22 · High
FlawOpen Security Research

Path Traversal in Java (java.nio.file, Spring)

Paths.get(UPLOAD_DIR, file) follows ../ segments, String.startsWith accepts sibling folders, and ZipEntry names can escape an extraction folder. This Java guide shows held SecureDirectoryStream reads and bounded flat archive extraction using server-generated file IDs.

💡 Plain English Explainer (ELI5)

Picture a museum archive where a curator keeps one vault door open with the vault’s own key while retrieving a file. A visitor’s label is never treated as a route through the building. Path.startsWith(Path) compares whole path components, and toRealPath() resolves existing links, but a check followed by a separate open can still lose the race. SecureDirectoryStream performs the read relative to the held directory handle; NOFOLLOW_LINKS rejects a symlink at the leaf, and the handle stays open through the read. For ZIPs, treat ZipEntry names as labels only: validate a flat name, write under a server-generated UUID, and enforce size and count limits. Fail closed if the filesystem provider lacks SecureDirectoryStream.

Core Concepts & Subsystem Terms

Paths.get(base, name) and Path.resolve()
Paths.get("/srv/uploads", "../../etc/hostname") simply joins the strings, and the operating system resolves the .. segments when the file is opened. Path.resolve() is worse for absolute input: base.resolve("/etc/hostname") returns /etc/hostname.
String.startsWith on a canonical path
getCanonicalPath() removes .. and follows links, but canonical.startsWith("/srv/uploads") compares characters, so /srv/uploads-old/secret.txt passes. Compare Path objects, not strings.
normalize() and Path.startsWith()
normalize() removes . and .. lexically; Path.startsWith(Path) then compares whole name elements, so /srv/uploads-old does not start with /srv/uploads. This is the containment check for files that do not exist yet.
toRealPath() and symbolic links
For existing files, toRealPath() resolves symbolic links. Comparing it with the base directory's own real path catches a link inside the upload folder that points to /etc or another tenant's data.
Zip Slip (CVE-2018-1002200)
Archive entry names such as ../../WEB-INF/web.xml are written outside the target by code that resolves them onto it. Snyk's 2018 Zip Slip research found this in many Java libraries, including plexus-archiver before 3.6.0 (CVE-2018-1002200).

Framework mappings

  • CWE reference for the weakness described in this guide: CWE-22

Step-by-Step Attack Flow

Step 1

Request value reaches a file API

A Spring controller passes @RequestParam String file, or a ZipEntry name from an uploaded archive, to Paths.get, new File or Path.resolve.

Step 2

The value leaves the intended folder

It contains ../ segments, is absolute, names a sibling folder with the same prefix, or reaches a symbolic link. The benign test value ../../etc/hostname illustrates the read case.

Step 3

The JVM opens the resolved path

PathResource, Files.readString or Files.copy pass the path to the operating system, which follows every .. and link.

Step 4

The attacker reads or overwrites files

Reads expose application.properties, keys and source; archive writes can replace JSPs, templates or JARs that the server loads later.

Source Code: Flaw vs. Secure Implementation

This example assumes a flat store of server-generated file IDs. SecureDirectoryStream performs relative operations through an open directory handle; NOFOLLOW_LINKS rejects a symlink at the leaf, and the handle stays open through the read. The JDK filesystem provider must support SecureDirectoryStream, so the code fails closed otherwise. Keep the configured directory and its ancestors service-controlled, and store only regular files. Archive extraction rejects nested paths and existing names, with per-entry, total-byte, and entry-count limits. This is a generic defense pattern, not a framework-specific patch. Archive input is capped at 100 MB, and directory entries are rejected before they can be drained. Archive writes are bounded and create-only, but this example is not transactional: a later failure can leave earlier files in place. Production importers should stage the archive in a private temporary directory and publish only after all entries pass, or clean up files created during a failed import. Archive entry labels are checked for flat structure, then replaced with server-generated UUIDs before any filesystem write.

UNPATCHED FLAW
// FileController.java: each method lets the request pick a path outside UPLOAD_DIR
package com.example.files;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class FileController {
    private static final String UPLOAD_DIR = "/srv/uploads";

    // Paths.get keeps ../ and the OS follows it
    @GetMapping("/download")
    public Resource download(@RequestParam String file) {
        return new PathResource(Paths.get(UPLOAD_DIR, file));
    }

    // String.startsWith also accepts /srv/uploads-old/...
    @GetMapping("/preview")
    public String preview(@RequestParam String file) throws IOException {
        String canonical = new File(UPLOAD_DIR, file).getCanonicalPath();
        if (!canonical.startsWith(UPLOAD_DIR)) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        return Files.readString(Path.of(canonical));
    }

    // an entry named ../../WEB-INF/web.xml is written outside dest
    public static void importArchive(InputStream in, Path dest) throws IOException {
        try (ZipInputStream zip = new ZipInputStream(in)) {
            for (ZipEntry e; (e = zip.getNextEntry()) != null; ) {
                if (e.isDirectory()) continue;
                Path target = dest.resolve(e.getName());
                Files.createDirectories(target.getParent());
                Files.copy(zip, target);
            }
        }
    }
}
HARDENED SECURE PATCH
// FileController.java: flat names opened relative to a held directory handle
package com.example.files;

import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SecureDirectoryStream;
import java.nio.file.StandardOpenOption;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class FileController {
    private static final Path UPLOAD_DIR = Path.of("/srv/uploads").toAbsolutePath().normalize();
    private static final int MAX_ENTRY_BYTES = 1_048_576;
    private static final int MAX_TOTAL_BYTES = 100_000_000;
    private static final int MAX_ENTRIES = 1000;
    private static final long MAX_ARCHIVE_BYTES = 100_000_000L;
    private static final Pattern SERVER_NAME = Pattern.compile("[A-Za-z0-9_-]{1,80}");

    // This example uses a flat store: reject paths and accept one server-generated name.
    static Path flatName(String raw) {
        Path name = Path.of(raw);
        if (!SERVER_NAME.matcher(raw).matches() || raw.contains("/") || raw.contains("\\") || name.isAbsolute()
                || name.getNameCount() != 1) {
            throw new IllegalArgumentException("expected one file name");
        }
        return name;
    }

    // Some providers do not support race-resistant relative operations; fail closed.
    @SuppressWarnings("unchecked")
    static SecureDirectoryStream<Path> secureRoot(Path directory) throws IOException {
        DirectoryStream<Path> stream = Files.newDirectoryStream(directory);
        if (!(stream instanceof SecureDirectoryStream<?>)) {
            stream.close();
            throw new IOException("SecureDirectoryStream is required");
        }
        return (SecureDirectoryStream<Path>) stream;
    }

    // Open the leaf relative to the held root; keep that handle alive through the read.
    static InputStream openInside(Path directory, String rawName) throws IOException {
        Path name = flatName(rawName);
        SecureDirectoryStream<Path> root = secureRoot(directory);
        SeekableByteChannel channel;
        try {
            channel = root.newByteChannel(name,
                    Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS));
        } catch (IOException | RuntimeException e) {
            root.close();
            throw e;
        }
        return new FilterInputStream(Channels.newInputStream(channel)) {
            @Override public void close() throws IOException {
                try { super.close(); } finally { root.close(); }
            }
        };
    }

    @GetMapping("/download")
    public Resource download(@RequestParam String file) {
        try {
            return new InputStreamResource(openInside(UPLOAD_DIR, file));
        } catch (IOException | IllegalArgumentException e) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "not found", e);
        }
    }

    @GetMapping("/preview")
    public String preview(@RequestParam String file) throws IOException {
        try (InputStream in = openInside(UPLOAD_DIR, file)) {
            return new String(in.readNBytes(2000), java.nio.charset.StandardCharsets.UTF_8);
        } catch (IllegalArgumentException e) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "not found", e);
        }
    }

    // Validate the archive label, but never use it as a filesystem output name.
    static void validateFlatEntry(String raw) {
        Path entry = Path.of(raw);
        if (raw.isEmpty() || raw.contains("/") || raw.contains("\\") || entry.isAbsolute()
                || entry.getNameCount() != 1 || raw.equals(".") || raw.equals("..")) {
            throw new IllegalArgumentException("expected one flat archive entry");
        }
    }

    // Stop compressed-input growth too; exact-limit archives are accepted, larger ones fail.
    static InputStream limitArchive(InputStream source) {
        return new FilterInputStream(source) {
            private long remaining = MAX_ARCHIVE_BYTES;
            @Override public int read() throws IOException {
                if (remaining == 0) {
                    if (super.read() == -1) return -1;
                    throw new IOException("archive input too large");
                }
                int value = super.read();
                if (value != -1) remaining--;
                return value;
            }
            @Override public int read(byte[] bytes, int offset, int length) throws IOException {
                if (length == 0) return 0;
                if (remaining == 0) {
                    if (super.read() == -1) return -1;
                    throw new IOException("archive input too large");
                }
                int count = super.read(bytes, offset, (int) Math.min(length, remaining));
                if (count > 0) remaining -= count;
                return count;
            }
        };
    }

    // This flat archive example rejects directory and nested-path entries; create new files only.
    public static void importArchive(InputStream in, Path destination) throws IOException {
        try (SecureDirectoryStream<Path> root = secureRoot(destination.toAbsolutePath().normalize());
             ZipInputStream zip = new ZipInputStream(limitArchive(in))) {
            int entries = 0;
            int totalBytes = 0;
            for (ZipEntry entry; (entry = zip.getNextEntry()) != null; ) {
                if (++entries > MAX_ENTRIES) throw new IOException("too many archive entries");
                if (entry.isDirectory()) throw new IOException("directory entries are not accepted");
                try { validateFlatEntry(entry.getName()); }
                catch (IllegalArgumentException e) { throw new IOException("unsafe archive entry", e); }
                Path name = Path.of(UUID.randomUUID().toString());
                byte[] contents = zip.readNBytes(MAX_ENTRY_BYTES + 1);
                if (contents.length > MAX_ENTRY_BYTES) throw new IOException("archive entry too large");
                if (contents.length > MAX_TOTAL_BYTES - totalBytes) throw new IOException("archive too large");
                totalBytes += contents.length;
                try (SeekableByteChannel channel = root.newByteChannel(name,
                        Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE,
                               LinkOption.NOFOLLOW_LINKS))) {
                    Channels.newOutputStream(channel).write(contents);
                }
            }
        }
    }
}

Engineering & System Hardening Checklist

Sources