flawopen.com/path-traversal/Java
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.
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 pathgetCanonicalPath()removes..and follows links, butcanonical.startsWith("/srv/uploads")compares characters, so/srv/uploads-old/secret.txtpasses. ComparePathobjects, not strings.normalize() and Path.startsWith()normalize()removes.and..lexically;Path.startsWith(Path)then compares whole name elements, so/srv/uploads-olddoes 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/etcor another tenant's data. Zip Slip (CVE-2018-1002200)- Archive entry names such as
../../WEB-INF/web.xmlare 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
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.
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.
The JVM opens the resolved path
PathResource, Files.readString or Files.copy pass the path to the operating system, which follows every .. and link.
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.
// 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);
}
}
}
}
// 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
- ✓Enable Find Security Bugs PATH_TRAVERSAL_IN and flag
ZipEntry.getName()flowing into filesystem operations. - ✓Use server-generated one-component file IDs; reject separators, absolute names, and dot segments instead of accepting arbitrary paths.
- ✓Open files relative to a held SecureDirectoryStream with NOFOLLOW_LINKS; fail closed when the filesystem provider lacks support.
- ✓Keep the configured storage directory and ancestors service-controlled; accept only regular files and prevent untrusted principals from creating links or special files there.
- ✓For archives, reject nested paths, create files with CREATE_NEW and NOFOLLOW_LINKS, and enforce per-entry size, total-byte, and entry-count limits. Replace archive entry labels with server-generated IDs; never use client-provided names as output paths.
- ✓Stage archive extraction in a private temporary directory and publish atomically only after every entry passes, or remove partial files on failure.
Sources
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
- Java SE 21 API — Path.startsWith(Path)
- Java SE 21 API — Path.toRealPath
- Snyk — Zip Slip vulnerability
- NVD — CVE-2018-1002200 (plexus-archiver)
- Find Security Bugs — PATH_TRAVERSAL_IN
- OWASP — Path Traversal
- Oracle Java API — SecureDirectoryStream