Careeroza
Storing Files Safely
Never Trust the Uploaded File
- Never use the original file name as the path:
../../etc/passwdis a valid name (path traversal). Generate your own name. - Check the type by allowed extension and content, not only the client-sent
Content-Type. - Limit size and the number of files.
- Store files outside the application folder, or in object storage (S3, GCS, Azure Blob).
- Save only metadata (name, size, path, owner) in the database, not the bytes.
Safe Storage in Plain Java
Example
A storage service that validates the extension, generates its own name, refuses paths that escape the upload folder, and writes with java.nio.file (to a temp folder that is deleted at the end):
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
public class Main {
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("uploads");
Storage storage = new Storage(root);
try {
System.out.println(storage.store("pixel9-front.JPG", new byte[2048], "img-0001"));
System.out.println(storage.store("invoice.exe", new byte[10], "img-0002"));
System.out.println(storage.store("big-photo.png", new byte[6_000_000], "img-0003"));
System.out.println("escape attempt allowed? " + storage.isInside(root.resolve("../../etc/passwd")));
System.out.println("files stored: " + storage.count());
} finally {
try (var files = Files.walk(root)) {
files.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
}
}
}
}
class Storage {
private static final Set<String> ALLOWED = Set.of("jpg", "jpeg", "png", "webp");
private static final long MAX_BYTES = 5 * 1024 * 1024;
private final Path root;
Storage(Path root) { this.root = root.toAbsolutePath().normalize(); }
String store(String originalName, byte[] bytes, String id) throws IOException {
String ext = originalName.substring(originalName.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
if (!ALLOWED.contains(ext)) return "REJECTED " + originalName + ": type ." + ext + " not allowed";
if (bytes.length > MAX_BYTES) return "REJECTED " + originalName + ": larger than 5 MB";
Path target = root.resolve(id + "." + ext).normalize();
if (!isInside(target)) return "REJECTED: path outside upload folder";
Files.write(target, bytes);
return "STORED " + target.getFileName() + " (" + Files.size(target) + " bytes)";
}
boolean isInside(Path p) { return p.toAbsolutePath().normalize().startsWith(root); }
long count() throws IOException {
try (var s = Files.list(root)) { return s.count(); }
}
}Output
STORED img-0001.jpg (2048 bytes) REJECTED invoice.exe: type .exe not allowed REJECTED big-photo.png: larger than 5 MB escape attempt allowed? false files stored: 1
The Spring Service
@Service
@RequiredArgsConstructor
public class ImageStorageService {
private static final Set<String> ALLOWED = Set.of("image/jpeg", "image/png", "image/webp");
private final ProductImageRepository images;
@Value("${shopease.upload-dir:/var/shopease/uploads}")
private Path uploadDir;
public ImageResponse store(Long productId, MultipartFile file) {
if (file.isEmpty()) throw new BadRequestException("File is empty");
if (!ALLOWED.contains(file.getContentType())) throw new BadRequestException("Only JPG, PNG or WEBP");
String ext = StringUtils.getFilenameExtension(file.getOriginalFilename());
String name = UUID.randomUUID().toString().substring(0, 8) + "." + ext;
Path dir = uploadDir.resolve("products").resolve(productId.toString()).normalize();
try {
Files.createDirectories(dir);
try (InputStream in = file.getInputStream()) { // streams, does not load into memory
Files.copy(in, dir.resolve(name), StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
throw new UncheckedIOException("Could not store file", e);
}
images.save(new ProductImage(productId, name, file.getOriginalFilename(), file.getSize()));
return new ImageResponse(name, file.getOriginalFilename(), file.getSize(), file.getContentType(),
"/api/files/products/" + productId + "/" + name);
}
}Tip: In production, upload to S3 or Google Cloud Storage and save only the object key. Many apps let the browser upload straight to S3 using a pre-signed URL, so large files never pass through your server.