Directory Traversal in Java: Advanced Protection and Testing (Part 2)

20+ years in software development, now focused on application security. Writing hands-on guides on secure coding patterns, vulnerability analysis, and security architecture.
In previous part 1, we discussed the basics of directory traversal attacks, attack vectors, vulnerable code structures, and the essential mitigation techniques, including secure Spring Boot usage.
In this part, we will discuss the usage of Apache Commons IO, frontend security best practices, additional mitigation techniques like rate limiting and auditing, comprehensive testing strategies, and static analysis usage.
1. Using Apache Commons IO Safely
Apache Commons IO provides utility methods for file operations, but these must be used correctly to prevent directory traversal.
FilenameUtils for Filename Normalization
FilenameUtils.getName() extracts the final path component, removing directory traversal sequences:
import org.apache.commons.io.FilenameUtils;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
@RestController
public class FileController {
private static final Path BASE_DIR = Paths.get("/var/app/uploads")
.toAbsolutePath()
.normalize();
@GetMapping("/files/{filename}")
public ResponseEntity<Resource> getFile(@PathVariable String filename) {
try {
// Extract only the filename, removing path components
String safeName = FilenameUtils.getName(filename);
if (safeName == null || safeName.isEmpty()) {
return ResponseEntity.badRequest().build();
}
// Even after using FilenameUtils, perform canonical validation
Path requestedPath = BASE_DIR.resolve(safeName).normalize();
Path canonicalBase = BASE_DIR.toRealPath();
Path canonicalRequested = requestedPath.toRealPath();
if (!canonicalRequested.startsWith(canonicalBase)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Resource resource = new UrlResource(canonicalRequested.toUri());
return ResponseEntity.ok(resource);
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
What FilenameUtils.getName() accomplishes:
FilenameUtils.getName("../../../etc/passwd") // returns "passwd"
FilenameUtils.getName("../../sensitive.txt") // returns "sensitive.txt"
FilenameUtils.getName("/absolute/path/file.txt") // returns "file.txt"
Important limitation: FilenameUtils.getName() provides initial sanitization but does not validate the result against a base directory. Applications must still perform canonical path validation to prevent access to unintended files within the base directory.
FileUtils Unsafe Patterns
FileUtils provides convenient methods for file operations, but these do not include built-in directory traversal protections:
import org.apache.commons.io.FileUtils;
import java.io.File;
import java.io.IOException;
// VULNERABLE: FileUtils does not validate paths
public void copyUserFile(String sourceFilename, String destFilename) throws IOException {
File source = new File("/var/app/uploads/" + sourceFilename);
File dest = new File("/var/app/processed/" + destFilename);
// This will copy files outside intended directories if paths contain ../
FileUtils.copyFile(source, dest);
}
Secure usage requires explicit validation before invoking FileUtils methods:
import org.apache.commons.io.FileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class SecureFileCopyService {
private static final Path SOURCE_BASE = Paths.get("/var/app/uploads")
.toAbsolutePath()
.normalize();
private static final Path DEST_BASE = Paths.get("/var/app/processed")
.toAbsolutePath()
.normalize();
public void copyUserFileSafely(String sourceFilename, String destFilename) throws IOException {
// Validate source file - must exist for toRealPath() to work
Path sourcePath = SOURCE_BASE.resolve(sourceFilename).normalize();
Path canonicalSourceBase = SOURCE_BASE.toRealPath();
Path canonicalSource = sourcePath.toRealPath();
if (!canonicalSource.startsWith(canonicalSourceBase)) {
throw new SecurityException("Invalid source path");
}
// Validate destination - file may not exist yet
Path destPath = DEST_BASE.resolve(destFilename).normalize();
// Check that normalized path does not escape base directory
if (!destPath.startsWith(DEST_BASE)) {
throw new SecurityException("Invalid destination path");
}
// Validate the filename itself contains no traversal sequences
String destFileName = destPath.getFileName().toString();
if (destFileName.contains("..") || destFileName.contains("/") || destFileName.contains("\\")) {
throw new SecurityException("Invalid destination filename");
}
// Ensure parent directory exists and validate it
Path destParent = destPath.getParent();
if (destParent == null || !destParent.startsWith(DEST_BASE)) {
throw new SecurityException("Invalid destination directory");
}
Files.createDirectories(destParent);
// Verify parent directory is within bounds after creation
Path canonicalDestBase = DEST_BASE.toRealPath();
Path canonicalDestParent = destParent.toRealPath();
if (!canonicalDestParent.startsWith(canonicalDestBase)) {
throw new SecurityException("Destination directory escaped base path");
}
// Now safe to use FileUtils
FileUtils.copyFile(canonicalSource.toFile(), destPath.toFile());
}
}
2. Frontend Security Considerations
It is not possible for frontend code to directly lead to a directory traversal attack because the JavaScript code runs in a sandbox environment and does not have access to the file system. However, certain frontend design patterns can influence the likelihood of a backend implementation leading to a directory traversal attack.
Anti-Pattern: Exposing Server Paths to Client
Applications that send filesystem paths to the frontend create opportunities for backend developers to misuse these paths:
// ANTI-PATTERN: Frontend sending server paths
import { useState } from 'react';
function DownloadFile() {
const [serverPath, setServerPath] = useState("");
const handleDownload = async () => {
// Sending arbitrary path to backend encourages unsafe path handling
const response = await fetch(`/api/download?path=${encodeURIComponent(serverPath)}`);
const blob = await response.blob();
// Download logic...
};
return (
<div>
<input
type="text"
value={serverPath}
onChange={(e) => setServerPath(e.target.value)}
placeholder="Enter file path"
/>
<button onClick={handleDownload}>Download</button>
</div>
);
}
export default DownloadFile;
This pattern encourages backends to construct filesystem paths directly from user input, increasing the likelihood of traversal vulnerabilities.
Recommended Pattern: Opaque Identifiers
Instead of exposing filesystem paths, applications should use opaque identifiers or resource IDs:
// RECOMMENDED: Using opaque identifiers
import { useState } from 'react';
function DownloadFile() {
const [fileId, setFileId] = useState("");
const handleDownload = async () => {
try {
// Backend maps fileId to actual path securely
const response = await fetch(`/api/files/${encodeURIComponent(fileId)}`);
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
// Extract filename from Content-Disposition header
const disposition = response.headers.get('Content-Disposition');
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/);
anchor.download = filenameMatch ? filenameMatch[1] : 'download';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Download failed:', error);
}
};
return (
<div>
<input
type="text"
value={fileId}
onChange={(e) => setFileId(e.target.value)}
placeholder="Enter file ID"
/>
<button onClick={handleDownload}>Download</button>
</div>
);
}
export default DownloadFile;
Backend implementation for ID-based access using constructor injection:
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
@RestController
@RequestMapping("/api/files")
public class FileController {
private static final Path UPLOAD_BASE = Paths.get("/var/app/uploads")
.toAbsolutePath()
.normalize();
private final FileMetadataRepository fileRepository;
public FileController(FileMetadataRepository fileRepository) {
this.fileRepository = fileRepository;
}
@GetMapping("/{fileId}")
public ResponseEntity<Resource> downloadFile(@PathVariable String fileId) {
try {
// Map ID to filesystem path through database lookup
FileMetadata metadata = fileRepository.findById(fileId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// Database stores only safe filenames, never full paths
Path filePath = UPLOAD_BASE.resolve(metadata.getStoredFilename())
.normalize();
// Canonical path validation
Path canonicalBase = UPLOAD_BASE.toRealPath();
Path canonicalFile = filePath.toRealPath();
if (!canonicalFile.startsWith(canonicalBase)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Resource resource = new UrlResource(canonicalFile.toUri());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + metadata.getOriginalFilename() + "\"")
.body(resource);
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
This pattern completely decouples client-visible identifiers from filesystem paths, preventing any client-side manipulation from affecting path construction.
Secure File Upload Component
File upload components should use browser file selection and submit only file content, never allowing users to specify server-side destination paths:
// RECOMMENDED: Secure upload component
import { useState, type ChangeEvent } from 'react';
interface UploadResponse {
filename: string;
}
function FileUploader() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadStatus, setUploadStatus] = useState("");
const handleFileSelect = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
// Validate file type client-side for UX
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
setUploadStatus("Invalid file type");
return;
}
// Validate file size client-side for UX
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
setUploadStatus("File too large");
return;
}
setSelectedFile(file);
setUploadStatus("");
}
};
const handleUpload = async () => {
if (!selectedFile) return;
const formData = new FormData();
formData.append('file', selectedFile);
// Do NOT append destination paths or directory names
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (response.ok) {
const result: UploadResponse = await response.json();
setUploadStatus(`Upload successful: ${result.filename}`);
setSelectedFile(null);
} else {
setUploadStatus('Upload failed');
}
} catch (error) {
setUploadStatus('Upload error');
console.error('Upload failed:', error);
}
};
return (
<div>
<input
type="file"
onChange={handleFileSelect}
accept=".jpg,.jpeg,.png,.pdf"
/>
{selectedFile && (
<div>
<p>Selected: {selectedFile.name} ({(selectedFile.size / 1024).toFixed(2)} KB)</p>
<button onClick={handleUpload}>Upload</button>
</div>
)}
{uploadStatus && <p>{uploadStatus}</p>}
</div>
);
}
export default FileUploader;
Key security principles to remember. Browser file picker must be used exclusively, not text input for paths. Client-side validation is not a security control, it is a user experience tool. No ability for user to specify destination directories or subdirectories. Original filename from the browser is informational only and backend should validate or replace it. Backend controls all filesystem path decisions.
Document List with Safe Access
When displaying lists of downloadable documents, use resource identifiers rather than paths:
// RECOMMENDED: Document list with safe access pattern
import { useState, useEffect } from 'react';
interface Document {
id: string;
name: string;
uploadDate: string;
}
function DocumentList() {
const [documents, setDocuments] = useState<Document[]>([]);
useEffect(() => {
const fetchDocuments = async () => {
try {
const response = await fetch('/api/documents');
const docs: Document[] = await response.json();
setDocuments(docs);
} catch (error) {
console.error('Failed to fetch documents:', error);
}
};
fetchDocuments();
}, []);
const downloadDocument = async (docId: string, filename: string) => {
try {
const response = await fetch(`/api/documents/${docId}/download`);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Download failed:', error);
}
};
return (
<div>
<h2>Documents</h2>
<ul>
{documents.map(doc => (
<li key={doc.id}>
{doc.name} - {doc.uploadDate}
<button onClick={() => downloadDocument(doc.id, doc.name)}>
Download
</button>
</li>
))}
</ul>
</div>
);
}
export default DocumentList;
Backend document API:
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
public record DocumentDTO(
Long id,
String name,
LocalDateTime uploadDate
) {}
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
private static final Path DOCUMENT_BASE = Paths.get("/var/app/documents")
.toAbsolutePath()
.normalize();
private final DocumentRepository documentRepository;
public DocumentController(DocumentRepository documentRepository) {
this.documentRepository = documentRepository;
}
@GetMapping
public List<DocumentDTO> listDocuments() {
return documentRepository.findAll().stream()
.map(doc -> new DocumentDTO(
doc.getId(),
doc.getOriginalName(),
doc.getUploadDate()
))
.toList();
}
@GetMapping("/{id}/download")
public ResponseEntity<Resource> downloadDocument(@PathVariable Long id) {
try {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// storedFilename contains only validated filename, not path
Path filePath = DOCUMENT_BASE.resolve(doc.getStoredFilename()).normalize();
Path canonicalBase = DOCUMENT_BASE.toRealPath();
Path canonicalFile = filePath.toRealPath();
if (!canonicalFile.startsWith(canonicalBase)) {
throw new SecurityException("Path traversal attempt detected");
}
Resource resource = new UrlResource(canonicalFile.toUri());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + doc.getOriginalName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
3. Additional Defense Mechanisms
Security Headers and Content-Type Validation
When serving user-uploaded files, incorrect Content-Type headers can enable cross-site scripting attacks if HTML files are served with executable MIME types:
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
@RestController
public class SecureFileServingController {
private static final Path UPLOAD_BASE = Paths.get("/var/app/uploads")
.toAbsolutePath()
.normalize();
private static final Set<String> DANGEROUS_CONTENT_TYPES = Set.of(
"text/html",
"application/xhtml+xml",
"application/javascript",
"text/javascript",
"application/x-javascript",
"image/svg+xml"
);
private final FileMetadataRepository fileRepository;
public SecureFileServingController(FileMetadataRepository fileRepository) {
this.fileRepository = fileRepository;
}
@GetMapping("/files/{fileId}")
public ResponseEntity<Resource> serveFile(@PathVariable String fileId) {
try {
FileMetadata metadata = fileRepository.findById(fileId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Path filePath = UPLOAD_BASE.resolve(metadata.getStoredFilename()).normalize();
// Canonical path validation
Path canonicalBase = UPLOAD_BASE.toRealPath();
Path canonicalFile = filePath.toRealPath();
if (!canonicalFile.startsWith(canonicalBase)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Resource resource = new UrlResource(canonicalFile.toUri());
// Force download for potentially dangerous file types
String contentType = metadata.getContentType();
boolean forceDownload = isExecutableType(contentType);
ResponseEntity.BodyBuilder response = ResponseEntity.ok()
.contentType(MediaType.parseMediaType(
contentType != null ? contentType : MediaType.APPLICATION_OCTET_STREAM_VALUE));
if (forceDownload) {
response.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + metadata.getOriginalFilename() + "\"");
response.header("X-Content-Type-Options", "nosniff");
}
return response.body(resource);
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
private boolean isExecutableType(String contentType) {
if (contentType == null) {
return true; // Default to forcing download for unknown types
}
return DANGEROUS_CONTENT_TYPES.contains(contentType.toLowerCase());
}
}
Rate Limiting for File Access
Implement rate limiting on file access endpoints to prevent automated scanning. Spring Boot 3 applications can use Bucket4j or Resilience4j for rate limiting:
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@RestController
public class RateLimitedFileController {
// Per-IP rate limiting buckets
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
private final FileService fileService;
public RateLimitedFileController(FileService fileService) {
this.fileService = fileService;
}
@GetMapping("/files/{filename}")
public ResponseEntity<Resource> getFile(
@PathVariable String filename,
jakarta.servlet.http.HttpServletRequest request) {
String clientIp = getClientIp(request);
Bucket bucket = buckets.computeIfAbsent(clientIp, this::createNewBucket);
if (!bucket.tryConsume(1)) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}
return fileService.serveFileSecurely(filename);
}
private Bucket createNewBucket(String key) {
// Allow 10 requests per second with burst capacity of 20
Bandwidth limit = Bandwidth.classic(20, Refill.greedy(10, Duration.ofSeconds(1)));
return Bucket.builder()
.addLimit(limit)
.build();
}
private String getClientIp(jakarta.servlet.http.HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isEmpty()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
Audit Logging
Log all file access attempts, especially failures, to detect reconnaissance activities:
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.stream.Collectors;
@Aspect
@Component
public class FileAccessAuditAspect {
private static final Logger auditLogger = LoggerFactory.getLogger("FILE_ACCESS_AUDIT");
private static final int MAX_ARG_LENGTH = 100;
@Around("execution(* com.app.controller.*FileController.*(..)) && " +
"@annotation(org.springframework.web.bind.annotation.GetMapping)")
public Object auditFileAccess(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
String clientIp = getClientIp();
String sanitizedArgs = sanitizeArgs(args);
try {
Object result = joinPoint.proceed();
auditLogger.info("File access SUCCESS: method={}, args={}, ip={}",
methodName, sanitizedArgs, clientIp);
return result;
} catch (SecurityException e) {
auditLogger.warn("File access BLOCKED - traversal attempt: method={}, args={}, ip={}, error={}",
methodName, sanitizedArgs, clientIp, e.getMessage());
throw e;
} catch (Exception e) {
auditLogger.warn("File access FAILED: method={}, args={}, ip={}, error={}",
methodName, sanitizedArgs, clientIp, e.getMessage());
throw e;
}
}
private String getClientIp() {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest request = attrs.getRequest();
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isEmpty()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
return "unknown";
}
private String sanitizeArgs(Object[] args) {
// Truncate long arguments to prevent log injection and excessive log size
return Arrays.stream(args)
.map(arg -> {
if (arg == null) return "null";
String str = arg.toString()
.replace("\n", "")
.replace("\r", "");
return str.length() > MAX_ARG_LENGTH
? str.substring(0, MAX_ARG_LENGTH) + "..."
: str;
})
.collect(Collectors.joining(", "));
}
}
Monitor logs for patterns indicating traversal attempts:
File access BLOCKED - traversal attempt: method=getFile, args=[../../../etc/passwd], ip=192.168.1.100
File access BLOCKED - traversal attempt: method=getFile, args=[....//....//etc/passwd], ip=192.168.1.100
Multiple failed attempts from the same IP address or user session indicate active exploitation attempts. Configure alerting rules to notify security teams when these patterns emerge.
4. Testing and Verification
Unit Tests for Path Validation
Comprehensive unit tests should verify that path validation logic correctly rejects traversal attempts. Note that these are pure unit tests and do not require Spring context:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class PathValidationTest {
@TempDir
Path tempDir;
private Path baseDir;
private PathValidator validator;
@BeforeEach
void setup() throws IOException {
baseDir = tempDir.resolve("uploads");
Files.createDirectories(baseDir);
Files.writeString(baseDir.resolve("legitimate.txt"), "test content");
validator = new PathValidator(baseDir);
}
@Test
void shouldRejectBasicTraversalAttempt() {
// Path traversal attempts should fail because target does not exist
// or because validation catches the escape attempt
assertThrows(Exception.class, () -> {
validator.validateAndResolve("../../../etc/passwd");
});
}
@Test
void shouldRejectTraversalWithCurrentDir() {
assertThrows(Exception.class, () -> {
validator.validateAndResolve("./../../../etc/passwd");
});
}
@Test
void shouldRejectAbsolutePath() {
// Absolute paths should be rejected
SecurityException ex = assertThrows(SecurityException.class, () -> {
validator.validateAndResolve("/etc/passwd");
});
assertTrue(ex.getMessage().contains("Absolute paths not allowed"));
}
@Test
void shouldRejectSymlinkEscape() throws IOException {
// Create symlink pointing outside base directory
Path outsideTarget = tempDir.resolve("outside.txt");
Files.writeString(outsideTarget, "outside content");
Path symlink = baseDir.resolve("evil_link");
Files.createSymbolicLink(symlink, outsideTarget);
assertThrows(SecurityException.class, () -> {
validator.validateAndResolve("evil_link");
});
}
@Test
void shouldAllowLegitimateAccess() throws IOException {
Path result = validator.validateAndResolve("legitimate.txt");
assertNotNull(result);
assertTrue(result.startsWith(baseDir.toRealPath()));
assertEquals("test content", Files.readString(result));
}
@Test
void shouldAllowNestedLegitimateAccess() throws IOException {
Path subdir = baseDir.resolve("subdir");
Files.createDirectories(subdir);
Files.writeString(subdir.resolve("nested.txt"), "nested content");
Path result = validator.validateAndResolve("subdir/nested.txt");
assertNotNull(result);
assertTrue(result.startsWith(baseDir.toRealPath()));
}
@Test
void shouldRejectNullInput() {
assertThrows(IllegalArgumentException.class, () -> {
validator.validateAndResolve(null);
});
}
@Test
void shouldRejectEmptyInput() {
assertThrows(IllegalArgumentException.class, () -> {
validator.validateAndResolve("");
});
}
// The class under test
static class PathValidator {
private final Path baseDir;
PathValidator(Path baseDir) {
this.baseDir = baseDir.toAbsolutePath().normalize();
}
Path validateAndResolve(String userInput) throws IOException {
if (userInput == null || userInput.isEmpty()) {
throw new IllegalArgumentException("Filename cannot be null or empty");
}
// Reject absolute paths early
if (userInput.startsWith("/") || userInput.startsWith("\\")) {
throw new SecurityException("Absolute paths not allowed");
}
Path requested = baseDir.resolve(userInput).normalize();
// First check: normalized path must stay within base
if (!requested.startsWith(baseDir)) {
throw new SecurityException("Path escapes base directory");
}
// Second check: canonical path validation (handles symlinks)
Path canonicalBase = baseDir.toRealPath();
Path canonicalRequested = requested.toRealPath(); // Throws if file does not exist
if (!canonicalRequested.startsWith(canonicalBase)) {
throw new SecurityException("Path outside base directory after symlink resolution");
}
return canonicalRequested;
}
}
}
Integration Tests
Integration tests should verify end-to-end protection including HTTP request handling:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class FileControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldBlockBasicTraversalAttempt() throws Exception {
mockMvc.perform(get("/api/files/download/{filename}", "../../../etc/passwd"))
.andExpect(status().isForbidden());
}
@Test
void shouldBlockDoubleEncodedTraversal() throws Exception {
// Double-encoded ../ sequence - Spring will decode once, leaving %2F
mockMvc.perform(get("/api/files/download/{filename}", "..%252F..%252Fetc%252Fpasswd"))
.andExpect(status().isForbidden());
}
@Test
void shouldBlockAbsolutePath() throws Exception {
mockMvc.perform(get("/api/files/download/{filename}", "/etc/passwd"))
.andExpect(status().isForbidden());
}
@Test
void shouldBlockNullByteInjection() throws Exception {
mockMvc.perform(get("/api/files/download/{filename}", "valid.txt\u0000../../../etc/passwd"))
.andExpect(status().isForbidden());
}
@Test
void shouldBlockBackslashTraversal() throws Exception {
mockMvc.perform(get("/api/files/download/{filename}", "..\\..\\..\\etc\\passwd"))
.andExpect(status().isForbidden());
}
}
Manual Security Testing
Perform manual security testing with various attack payloads:
# Basic traversal
curl -v "http://localhost:8080/api/files/download/../../../etc/passwd"
# URL encoded
curl -v "http://localhost:8080/api/files/download/..%2F..%2F..%2Fetc%2Fpasswd"
# Double encoded
curl -v "http://localhost:8080/api/files/download/..%252F..%252F..%252Fetc%252Fpasswd"
# Mixed separators (test on Windows)
curl -v "http://localhost:8080/api/files/download/..\..\..\windows\system32\config\sam"
# Overlong UTF-8 sequences
curl -v "http://localhost:8080/api/files/download/..%c0%af..%c0%af..%c0%afetc/passwd"
# Absolute path with double slash
curl -v "http://localhost:8080/api/files/download//etc/passwd"
# Path with null byte (legacy vulnerability)
curl -v "http://localhost:8080/api/files/download/valid.txt%00../../../etc/passwd"
# Backslash on Unix (sometimes processed differently)
curl -v "http://localhost:8080/api/files/download/..\\..\\..\\etc\\passwd"
All these requests should return 403 Forbidden or 400 Bad Request. None should return file contents from outside the intended directory.
5. Static Analysis and Security Scanning
Static Application Security Testing
Integrate SAST tools into the CI/CD pipeline to automatically detect directory traversal vulnerabilities.
SpotBugs with FindSecBugs plugin identifies path traversal patterns:
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.9.8.2</version>
<configuration>
<effort>Max</effort>
<threshold>Low</threshold>
<failOnError>true</failOnError>
<plugins>
<plugin>
<groupId>com.h3xstream.findsecbugs</groupId>
<artifactId>findsecbugs-plugin</artifactId>
<version>1.14.0</version>
</plugin>
</plugins>
</configuration>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
FindSecBugs identifies patterns such as PATH_TRAVERSAL_IN in the usage of user input in file path construction, PATH_TRAVERSAL_OUT in the usage of user input in file write operations, and WEAK_FILENAMEUTILS in the usage of insufficient file name validation through FilenameUtils.
SonarQube has strong security analysis capabilities. Directory traversal detection rules are available in SonarQube. Quality gates should be set up to fail the build if there are path traversal issues:
# sonar-project.properties
sonar.projectKey=myproject
sonar.sources=src/main/java
sonar.java.binaries=target/classes
sonar.qualitygate.wait=true
sonar.qualitygate.timeout=300
Semgrep allows custom rule definitions for organization-specific patterns:
rules:
- id: path-traversal-string-concat
patterns:
- pattern: new File(\(BASE + \)USER_INPUT)
message: Potential path traversal from string concatenation with File constructor
severity: ERROR
languages: [java]
metadata:
cwe: "CWE-22"
owasp: "A01:2025"
- id: path-traversal-resolve-no-validation
patterns:
- pattern-either:
- pattern: |
\(PATH.resolve(\)INPUT)
- pattern: |
Paths.get(\(BASE, \)INPUT)
- pattern-not-inside: |
...
$X.toRealPath()
...
- pattern-not-inside: |
...
if (!\(Y.startsWith(\)Z)) { ... }
...
message: Path resolution from user input without canonical validation
severity: WARNING
languages: [java]
metadata:
cwe: "CWE-22"
owasp: "A01:2025"
Dependency Scanning
Ensure third-party libraries are updated to receive security patches:
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>12.1.0</version>
<configuration>
<failBuildOnCVSS>7</failBuildOnCVSS>
</configuration>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
6. Common Pitfalls and Edge Cases
Case Sensitivity Issues
On case-insensitive filesystems like NTFS and APFS, path validation can behave unexpectedly:
// Potential issue on case-insensitive filesystem
Path base = Paths.get("/var/app/uploads");
Path requested = base.resolve("../UPLOADS/file.txt").normalize();
// Both might resolve to same physical location
// But string comparison would differ
Solution: Use toRealPath() which resolves to the actual filesystem path with correct casing. The canonical path returned by toRealPath() reflects the true filesystem representation:
private boolean isPathSafe(Path base, Path requested) throws IOException {
// toRealPath() returns canonical path with actual filesystem casing
Path canonicalBase = base.toRealPath();
Path canonicalRequested = requested.toRealPath();
// startsWith() on canonical paths handles case correctly
return canonicalRequested.startsWith(canonicalBase);
}
The key thing to understand here is that toRealPath() actually resolves the path against the filesystem itself, so the result is in whatever case the filesystem uses that name. In a case-insensitive filesystem, this will normalize the case correctly.
Race Conditions in Path Validation
Time-of-check to time-of-use (TOCTOU) vulnerabilities can occur if the filesystem changes between validation and access. An attacker with write access to the filesystem could potentially replace a validated file with a symlink between the check and the read operation.
// VULNERABLE: TOCTOU race condition window
public byte[] readFileSafely(String filename) throws IOException {
Path requested = BASE_DIR.resolve(filename).normalize();
Path canonicalRequested = requested.toRealPath(); // Check happens here
if (!canonicalRequested.startsWith(BASE_DIR.toRealPath())) {
throw new SecurityException("Invalid path");
}
// Gap between check and use - file could be replaced with symlink
return Files.readAllBytes(requested); // Use happens here - using original path!
}
Mitigation strategies:
// BETTER: Minimize TOCTOU window by using canonical path for read
public byte[] readFileSafely(String filename) throws IOException {
Path requested = BASE_DIR.resolve(filename).normalize();
Path canonicalBase = BASE_DIR.toRealPath();
Path canonicalRequested = requested.toRealPath();
if (!canonicalRequested.startsWith(canonicalBase)) {
throw new SecurityException("Invalid path");
}
// Read from the canonical path, not the original
return Files.readAllBytes(canonicalRequested);
}
For higher security requirements, consider using file descriptors with NOFOLLOW options:
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.io.IOException;
import java.io.InputStream;
// SAFER: Open with NOFOLLOW_LINKS to prevent symlink following
public byte[] readFileNoFollow(String filename) throws IOException {
Path requested = BASE_DIR.resolve(filename).normalize();
// Validate the path components first - reject obvious traversal
if (!requested.startsWith(BASE_DIR)) {
throw new SecurityException("Invalid path");
}
// Check if the file is a symbolic link before reading
if (Files.isSymbolicLink(requested)) {
throw new SecurityException("Symbolic links not allowed");
}
// Open without following symlinks
try (InputStream is = Files.newInputStream(requested, LinkOption.NOFOLLOW_LINKS)) {
return is.readAllBytes();
} catch (IOException e) {
// Could be a symlink that was created after our check, or other issue
throw new SecurityException("Cannot read file: " + e.getMessage());
}
}
Complete elimination of TOCTOU races requires operating system level support. In practice, minimize the window between validation and access, use canonical paths for the actual file operation, and ensure the application does not run with elevated privileges.
Insufficient Validation in Helper Methods
Ensure all code paths that access files perform validation:
// VULNERABLE: Helper method bypasses validation
public class FileService {
private static final Path BASE_DIR = Paths.get("/var/app/uploads");
public byte[] getFileSecurely(String filename) throws IOException {
validateFilename(filename);
return readFileInternal(filename);
}
// Another method might call readFileInternal directly
public byte[] getFileQuickly(String filename) throws IOException {
return readFileInternal(filename); // Bypasses validation!
}
private void validateFilename(String filename) {
// Validation logic here
}
private byte[] readFileInternal(String filename) throws IOException {
Path path = BASE_DIR.resolve(filename);
return Files.readAllBytes(path);
}
}
Solution: Perform validation in the lowest-level method that constructs paths:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
// SAFE: Validation in path construction method
public class FileService {
private static final Path BASE_DIR = Paths.get("/var/app/uploads")
.toAbsolutePath()
.normalize();
private Path validateAndGetPath(String filename) throws IOException {
if (filename == null || filename.isBlank()) {
throw new IllegalArgumentException("Filename cannot be null or empty");
}
// Reject absolute paths early
if (filename.startsWith("/") || filename.startsWith("\\")) {
throw new SecurityException("Absolute paths not allowed");
}
Path requested = BASE_DIR.resolve(filename).normalize();
// First check - normalized path
if (!requested.startsWith(BASE_DIR)) {
throw new SecurityException("Invalid path");
}
// Second check - canonical path with symlink resolution
Path canonicalBase = BASE_DIR.toRealPath();
Path canonicalRequested = requested.toRealPath();
if (!canonicalRequested.startsWith(canonicalBase)) {
throw new SecurityException("Invalid path after symlink resolution");
}
return canonicalRequested;
}
public byte[] getFileSecurely(String filename) throws IOException {
Path safePath = validateAndGetPath(filename);
return Files.readAllBytes(safePath);
}
public byte[] getFileQuickly(String filename) throws IOException {
Path safePath = validateAndGetPath(filename); // Always validated
return Files.readAllBytes(safePath);
}
}
This pattern ensures that every method obtaining a file path goes through the same validation. There is no way to accidentally bypass the security check because the path construction itself enforces validation.
Wrapping Part 2
Directory traversal vulnerabilities are still present in contemporary applications because they reflect a basic incompatibility between the application’s intent and the filesystem’s behavior. The filesystem will resolve paths based on its own rules, completely unaware of the security considerations at the application level. This gives an attacker complete control over filesystem access when an application combines filesystem paths with unvalidated user input.
The answer to this problem involves the strict application of canonical path validation across all code paths in which the filesystem is accessed. This should happen after all path resolution operations, including symbolic link resolution.
The Java environment has all the required constructs in place to ensure proper filesystem access. The Path API handles path operations in a platform-independent manner. The toRealPath() function is used to resolve the canonical path. The startsWith() function is used to validate the filesystem location. Spring Boot has a PathResourceResolver class that handles static resource serving.
As far as directory traversal prevention is concerned, it is similar to the prevention of SQL injection in the sense that it separates structure from data. In the case of SQL injection, it is necessary to first define the structure of a query before binding the data. In the case of directory traversal prevention, it is necessary to first define the boundaries of the file system before allowing the user input to influence them.
Security reviews should identify areas in the code where the structure of the file system paths is influenced by the user input. In such areas, it is necessary to have explicit canonical path validation or refactor them to make use of the framework's resource handling capabilities. In legacy code, administrative interfaces, file upload functionality, it is necessary to have a higher degree of concern regarding directory traversal prevention.
Defense in depth principles are additional security measures that provide higher security in comparison to canonical path validation. The principle of least privilege will limit the damage in the event of a successful attack. Input validation will provide an additional layer of filtering. In addition to this, audit logging will provide a mechanism to detect exploitation.
It is necessary to have canonical path validation in every filesystem operation that is influenced by the user input. There are no exceptions. There are no special cases. The code will either validate the paths correctly, or it will provide a security vulnerability through which an attacker will gain access to arbitrary files in the application's privilege context.





