Storing Files Safely

Never Trust the Uploaded File

  • Never use the original file name as the path: ../../etc/passwd is 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.