Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

File Handling & Object Storage

Handling uploads and downloads safely: multipart limits, detecting real content types with Tika, streaming large files, S3 and MinIO integration, and presigned URLs.

Intermediate6 min readUpdated
On this page

File upload endpoints are a well-known source of both outages and breaches. The failure modes are predictable — memory exhaustion, path traversal, content-type spoofing, unbounded storage growth — and each has a straightforward defence.

Key Takeaways

  • Set both multipart limits: per file and per request. The defaults are small and the errors are confusing.
  • Never trust the declared content type. Detect it from the bytes with Tika.
  • Generate your own storage filename; the client's is untrusted input and a path-traversal vector.
  • Stream both directions — getBytes() on a large upload is an OOM waiting for a busy day.
  • For anything user-facing at scale, prefer presigned URLs over proxying bytes.

Limits first

application.yml
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 20MB
      max-request-size: 60MB          # a multi-file request must fit here too
      file-size-threshold: 1MB        # below this stays in memory
      location: /var/tmp/uploads      # where larger parts spill to disk
server:
  tomcat:
    max-swallow-size: 25MB            # bytes Tomcat reads before aborting an oversize request

max-swallow-size is the setting nobody sets and everybody eventually needs. When a request exceeds the limit, Tomcat must still read some of it to send a clean error; if the body is much larger it resets the connection instead, and the client sees a network failure rather than your 413.

Give the limit breach a proper error:

UploadExceptionHandler.java
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ProblemDetail tooLarge(MaxUploadSizeExceededException ex) {
    var problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.PAYLOAD_TOO_LARGE, "The uploaded file exceeds the 20MB limit");
    problem.setTitle("File too large");
    problem.setType(URI.create("https://api.acme.com/errors/file-too-large"));
    return problem;
}

The upload pipeline

Every gate rejects before the bytes reach durable storage, and the client's filename never becomes a path.
FileUploadService.java
@Service
public class FileUploadService {
 
    private static final Set<String> ALLOWED =
            Set.of("image/jpeg", "image/png", "image/webp", "application/pdf");
 
    private final Tika tika = new Tika();
    private final ObjectStorage storage;
    private final FileMetadataRepository metadata;
 
    public StoredFile store(MultipartFile file, String uploadedBy) throws IOException {
        if (file.isEmpty()) throw new BadRequestException("empty file");
 
        // Detect from content, not from the declared header. Tika reads the
        // leading bytes, so this must happen before anything else consumes them.
        String detected;
        try (InputStream in = file.getInputStream()) {
            detected = tika.detect(in);
        }
        if (!ALLOWED.contains(detected)) {
            throw new UnsupportedMediaTypeException(detected);
        }
 
        // The client filename is untrusted: it may contain ../, a null byte, or
        // a second extension. Keep it as a display label only.
        String originalName = StringUtils.cleanPath(
                Objects.requireNonNullElse(file.getOriginalFilename(), "upload"));
        String storageKey = "%s/%s%s".formatted(
                LocalDate.now(), UUID.randomUUID(), extensionFor(detected));
 
        try (InputStream in = file.getInputStream()) {
            storage.put(storageKey, in, file.getSize(), detected);
        }
 
        return metadata.save(new StoredFile(
                storageKey, originalName, detected, file.getSize(), uploadedBy, Instant.now()));
    }
}

The ordering is deliberate. Detection happens first because it is cheap and rejects most bad uploads; storage happens last so nothing durable is written for a request that will be rejected.

Downloads without buffering

FileDownloadController.java
@GetMapping("/api/v1/files/{id}")
public ResponseEntity<Resource> download(@PathVariable UUID id, Authentication auth) {
 
    StoredFile file = metadata.findById(id).orElseThrow(() -> new ResourceNotFoundException("file", id));
    accessControl.assertCanRead(auth, file);   // authorise before touching storage
 
    InputStream stream = storage.open(file.storageKey());
 
    return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(file.contentType()))
            .contentLength(file.size())
            // attachment prevents the browser rendering an HTML or SVG payload
            // in your origin, which would be a stored XSS.
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    ContentDisposition.attachment()
                            .filename(file.originalName(), StandardCharsets.UTF_8)
                            .build().toString())
            .header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600")
            .body(new InputStreamResource(stream));
}

InputStreamResource streams straight from storage to the socket. The alternative — reading into a byte[] — means a 200MB download occupies 200MB of heap for its duration, and ten concurrent ones take the process down.

ContentDisposition.attachment() with an explicit charset handles non-ASCII filenames correctly by emitting the filename* form. It also stops a .svg or .html upload from being rendered inline, which is a genuine stored-XSS vector if your files are served from the application origin.

Presigned URLs

For any significant volume, taking the bytes out of your request path is the single biggest improvement available.

PresignedUploadService.java
@Service
public class PresignedUploadService {
 
    private final S3Presigner presigner;
 
    public PresignedUpload createUploadUrl(String contentType, long contentLength, String userId) {
        if (contentLength > 20 * 1024 * 1024) throw new BadRequestException("too large");
 
        String key = "uploads/%s/%s".formatted(userId, UUID.randomUUID());
 
        var request = PutObjectRequest.builder()
                .bucket("acme-user-content")
                .key(key)
                // Binding the type and length into the signature means the
                // client cannot upload something other than what we authorised.
                .contentType(contentType)
                .contentLength(contentLength)
                .build();
 
        var presigned = presigner.presignPutObject(b -> b
                .signatureDuration(Duration.ofMinutes(10))
                .putObjectRequest(request));
 
        return new PresignedUpload(presigned.url().toString(), key, presigned.expiration());
    }
}

The flow becomes: client asks your API for a URL, your API authorises and records intent, client uploads directly to storage, client tells your API it finished, your API verifies the object exists and has the expected size before marking it usable. That last verification step matters — without it, a client can claim an upload that never happened.

Keep expiry short. Ten minutes is generous for an upload and short enough that a leaked URL is worthless by the time it reaches anyone.

Metadata and lifecycle

Store metadata in your database, bytes in object storage. The table wants: storage key, original filename, detected content type, size, checksum, uploader, timestamp, and a status.

The status column is what makes cleanup possible. Objects get orphaned — a presigned upload the client abandoned, a row deleted while the object survived. A scheduled reconciliation that lists storage, compares against the table, and deletes objects with no live row keeps the bill from growing forever. Pair it with an object-storage lifecycle rule that expires anything in an uploads/ prefix older than a day and never confirmed.

Virus scanning

Any file that another user will download should be scanned. ClamAV over its clamd socket is the usual choice and integrates as a stream:

VirusScanner.java
public ScanResult scan(InputStream stream) {
    try (var client = new ClamavClient(host, port)) {
        return client.scan(stream) instanceof ScanResult.OK ? ScanResult.clean() : ScanResult.infected();
    }
}

Scan asynchronously for large files: accept the upload, store it in a quarantine prefix, mark the row PENDING_SCAN, and promote it once the scan passes. Blocking the request on a scan makes upload latency depend on a service that occasionally takes seconds.

What to take away

Set the limits, detect the type from the bytes, generate your own filename, and stream in both directions. Once volume justifies it, move the transfer itself to presigned URLs and keep your service responsible only for authorisation and metadata.

Frequently Asked Questions

Why should I not trust the Content-Type header on an upload?
It is supplied by the client and trivially forged. A file can claim to be image/png while containing a shell script. Detect the type from the leading bytes with Apache Tika and compare the result to your allowlist; the declared header is a hint, never a control.
How do I handle files larger than available memory?
Never call getBytes(). Stream through getInputStream() and copy to the destination in chunks, or use transferTo which lets the container move the temp file directly. For downloads, return an InputStreamResource or a StreamingResponseBody so the payload is never fully buffered.
Should uploads go through my application at all?
Often not. A presigned URL lets the browser upload directly to object storage, so your service never carries the bytes and never becomes the bandwidth bottleneck. Your API issues the URL after an authorisation check and records the metadata; storage handles the transfer.

Related tutorials