Skip to main content

File Upload and Download in Spring Boot 4: Multipart, Streaming and S3 Presigned URLs

File upload and download in Spring Boot 4.1: why MockMvc accepts files Tomcat rejects, the filename you must not trust, Range downloads, a 200 MB transfer under a 64 MB heap, and what an S3 presigned URL does and does not protect.

File upload code usually works on the first try and fails in production for reasons the first try could not show. The unit test passes a 5 MB file that the real server refuses. The filename the client sent turns out to be a path. The download endpoint that read the file into a byte[] works until somebody asks for a large one. This post goes through those failures one at a time, in a small Spring Boot 4.1 project you can run without Docker, and ends with the part that removes your server from the data path altogether: S3 presigned URLs.

Versions: Spring Boot 4.1.1, Spring Framework 7.0.9, Tomcat 11.0.24, JDK 25.0.4.1, AWS SDK for Java 2.56.0. All code is in the file-transfer module of the spring-boot-demo repository. ./scripts/run-all.sh reruns everything and rewrites the captured output under cap/.

What was not run: there is no Docker here, so no real S3 or MinIO. The presigned URLs are made by the real AWS SDK and checked by a small local server that I wrote to verify the AWS Signature V4 algorithm. That tests consistency with the documented algorithm, not acceptance by a real S3 server. The details are in the S3 section.

What goes wrong, in the order you meet it

  • The size limit is not enforced by your code or by MockMvc. Tomcat enforces it. A test that passes proves nothing about it (section 1).
  • Very large rejected uploads may never see the 413. The client can get a connection error instead, and it is racy (section 1).
  • getOriginalFilename() is attacker-controlled. It returned ../../etc/cron.d/evil.txt verbatim (section 2).
  • Returning the declared content type lets an uploader choose how browsers render the file. Fix the type on the way out (section 3).
  • Buffering the download in memory fails under a small heap. A 200 MB response returned HTTP 500 on a 64 MB heap, while streaming it succeeded (section 4).
  • A presigned URL is a bearer token. It works for anyone holding it until it expires, as many times as they like (section 5).
Where the bytes travel 1. Multipart Client Spring app (parses) Disk 2. Streamed Client Spring app (copies) Disk or S3 3. Presigned Client Spring app (signs) Object storage Only the signed URL passes through the app in case 3; the file never does.
The three paths covered below. In cases 1 and 2 every byte crosses your JVM. In case 3 only a short URL does.

The rest of the post follows that figure: limits and filenames for the multipart path, then Range and streaming for the bytes that do cross the JVM, then presigning for the ones that should not. For the underlying file APIs (Files.copy, Path), see the Java NIO.2 guide; for the older Jersey approach, the Jersey file upload example.

1. Multipart limits belong to the container

Two properties set the limits. Spring passes them to the servlet container, and Tomcat does the enforcing while it parses the request, before your controller runs.

# application.properties
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=2MB

Source: application.properties

The controller is deliberately plain. The error handler turns the container’s failure into a JSON problem instead of an empty response:

@PostMapping(path = "/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<UploadResult> upload(@RequestParam("file") MultipartFile file) throws IOException {
    if (file.isEmpty()) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "empty file");
    }
    return created(store.save(file));
}

@RestControllerAdvice
public class UploadErrors {
    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ProblemDetail tooLarge(MaxUploadSizeExceededException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONTENT_TOO_LARGE,
                "The upload is larger than the configured limit (spring.servlet.multipart.max-file-size / max-request-size).");
        pd.setTitle("Upload too large");
        return pd;
    }
}

Source: UploadController.java, UploadErrors.java

The test sends real HTTP requests to the running server and, for comparison, the same file through MockMvc:

limits: max-file-size=1MB, max-request-size=2MB
real HTTP, one 500 KB file           -> status=201 body={"id":"d8d0b4b8-d530-42aa-b005-9e1b747449a7","originalFilename":"a.txt","displayName":"a.txt","size":512000,"declaredContentType":"text/plai...
real HTTP, one 1.5 MB file           -> status=413 body={"detail":"The upload is larger than the configured limit (spring.servlet.multipart.max-file-size / max-request-size).","instance":"/files",...
real HTTP, 3 x 900 KB in one request -> status=413 body={"detail":"The upload is larger than the configured limit (spring.servlet.multipart.max-file-size / max-request-size).","instance":"/files",...
real HTTP, one 10 MB file, 10 attempts -> 7 x status=413, 3 x connection error
MockMvc, the same 1.5 MB file        -> status=201

Source: cap/01-limits.txt, produced by MultipartLimitsTest.java

Three results matter. A 1.5 MB file is rejected by the real server and accepted by MockMvc, because MockMvc never starts a servlet container, so the limit is never applied. A request made of three files that are each under the per-file limit is still rejected, because the total is over the per-request limit. And the 10 MB file, which is far over the limit, did not reliably get its 413.

Going deeper: why the 10 MB upload sometimes returns no response at all

Across three runs of that test, the 10 MB upload got a 413 in 4, 5 and 7 of 10 attempts, and a connection error in the rest. The client is still writing a 10 MB body while the server has already decided to reject it. My reading, from the Tomcat connector documentation, is that Tomcat only reads and discards a limited amount of a rejected body (the maxSwallowSize setting, 2 MB by default) before it closes the connection; a client that is still sending then sees a reset, not the response. I did not change that setting to confirm it, so treat it as the likely cause and not a proven one.

The practical consequence is on the client side: treat “connection reset while uploading” as a possible size rejection, and check the size before sending when you control the client. For large files, the answer is not to raise the multipart limit. It is to avoid multipart for them (section 4) or to bypass the server (section 5).

2. The filename is attacker input

MultipartFile.getOriginalFilename() returns what the client put in the request. A browser sends a bare name, but nothing makes an attacker’s client do the same. Used as a path, it writes outside your directory. This module never uses it as a path. The file is stored under a server-generated UUID, and the original name is kept only as display metadata after stripping everything up to the last separator:

/** Last path segment only, control characters removed. Handles both / and \ separators. */
static String displayName(String raw) {
    if (raw == null || raw.isBlank()) {
        return "unnamed";
    }
    String name = raw.replace('\\', '/');
    name = name.substring(name.lastIndexOf('/') + 1).replaceAll("\\p{Cntrl}", "");
    return name.isBlank() ? "unnamed" : name;
}

Source: FileStore.java

The test uploads a file whose filename is a path traversal and whose declared type is image/png although the content is the text hello:

client sent filename=../../etc/cron.d/evil.txt Content-Type=image/png
server response: {"id":"4980219c-b92a-46d4-a5ab-b5d7636fb7f0","originalFilename":"../../etc/cron.d/evil.txt","displayName":"evil.txt","size":5,"declaredContentType":"image/png"}
root.resolve(originalFilename).normalize() stays inside the storage dir: false
client sent a Windows-style path; server response: {"id":"d436e57e-6855-4fff-b5ca-b9dd0fec25c2","originalFilename":"..\\..\\windows\\system32\\evil.dll","displayName":"evil.dll","size":1,"declaredContentType":"text/plain"}

Source: cap/02-filename.txt, produced by FilenameAndDownloadTest.java

Two things to read from this. The original filename arrives untouched, so root.resolve(originalFilename).normalize() lands outside the storage directory (false on the third line). And the declared content type is simply the client’s claim: the server was told image/png for five bytes of text. Neither value should decide anything security-relevant. The OWASP unrestricted file upload page is the checklist for what else to validate (extension allow-list, content sniffing, storage outside the web root).

3. Downloads: let Resource do the work

Return a Resource inside a ResponseEntity and Spring handles Range requests itself. The content type is pinned to application/octet-stream with nosniff so the type the uploader declared is never replayed to a browser:

@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable String id) {
    FileStore.StoredFile f = store.find(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such file"));
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    ContentDisposition.attachment().filename(f.displayName(), StandardCharsets.UTF_8).build().toString())
            .header("X-Content-Type-Options", "nosniff")
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(new FileSystemResource(f.path()));
}

Source: DownloadController.java

The test uploads a 26-byte file named résumé 2026.txt (with accents) and then downloads it three ways:

GET /files/{id}
  status=200 body=ABCDEFGHIJKLMNOPQRSTUVWXYZ
  content-type: application/octet-stream
  content-length: 26
  content-disposition: attachment; filename="resume 2026.txt"; filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt
  accept-ranges: bytes
  x-content-type-options: nosniff
GET /files/{id} with Range: bytes=10-19
  status=206 body=KLMNOPQRST
  content-range: bytes 10-19/26
GET /files/{id} with Range: bytes=500-
  status=416 content-range: bytes */26

Source: cap/03-download.txt

A valid range returns 206 Partial Content with the matching Content-Range; a range past the end returns 416 and tells the client the real size. That is what makes resumable downloads and video seeking work without any code from you.

Going deeper: the two filenames in Content-Disposition

The header has two parameters. filename="resume 2026.txt" is the ASCII fallback for old clients; Spring replaced the accented letters with plain ones there. filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt carries the real name, percent-encoded as RFC 6266 describes, and modern browsers prefer it. Building the header with ContentDisposition, and not by string concatenation, is what produces both and handles the quoting. A hand-built "attachment; filename=" + name breaks on the first space or non-ASCII letter, and with a quote in the name it can let the filename end the parameter early.

4. Streaming: keep the heap out of it

The same 200 MB of data, sent two ways. /buffered/{mb} builds a byte[] first. /stream/{mb} writes 64 KB chunks through StreamingResponseBody:

@GetMapping("/stream/{mb}")
public ResponseEntity<StreamingResponseBody> stream(@PathVariable int mb) {
    long total = (long) mb * 1024 * 1024;
    StreamingResponseBody body = out -> {
        byte[] chunk = new byte[CHUNK];                       // CHUNK = 64 * 1024
        Arrays.fill(chunk, (byte) 'x');
        for (long written = 0; written < total; written += CHUNK) {
            out.write(chunk, 0, (int) Math.min(CHUNK, total - written));
        }
    };
    return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(total).body(body);
}

@GetMapping(path = "/buffered/{mb}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public byte[] buffered(@PathVariable int mb) {
    byte[] all = new byte[mb * 1024 * 1024];
    Arrays.fill(all, (byte) 'x');
    return all;
}

Source: StreamController.java

The script starts the packaged jar with a 64 MB heap (-Xmx64m) and calls both with curl:

JVM started with -Xmx64m
GET /buffered/200 (200 MB) -> status=500 downloaded=108 bytes in 0.273337s
GET /stream/200 (200 MB) -> status=200 downloaded=209715200 bytes in 0.337059s
GET /stream/1 afterwards -> status=200

Source: cap/05-streaming.txt, produced by run-all.sh

The buffered endpoint returned a 500. I re-ran it once and the application log shows OutOfMemoryError: Java heap space, so the heap cause is confirmed. The streaming endpoint delivered all 209,715,200 bytes from the same small heap. The app also kept serving afterwards, which is not guaranteed once an OutOfMemoryError has been thrown, so do not read that as “it is fine to let it happen”.

Uploads have the same choice. With the multipart limits removed (-1), a 150 MB multipart upload and a 150 MB raw-body upload both succeeded on the 64 MB heap:

JVM started with -Xmx64m, multipart limits removed, uploading a 150 MB file
multipart POST /files -> status=201 sent=157286616 bytes in 1.518657s
raw POST /files/raw   -> status=201 sent=157286400 bytes in 1.850810s

Source: cap/06-big-upload.txt

Going deeper: multipart vs raw body, and what neither run tells you

Multipart parsing happens in the container, which writes large parts to a temporary file, so memory stays flat as the run above shows. The raw endpoint takes the request body as an InputStream and copies it with Files.copy, and it is not subject to the multipart limits at all, so it needs its own size check if you expose it. Raw bodies are what you want for programmatic clients and for resumable protocols; browsers uploading from a form will use multipart.

What the 150 MB run does not tell you: it ran on one machine over the loopback interface, so the timings (about 1.5 s and 1.9 s) say nothing about real network behaviour, slow clients holding connections open, or disk pressure from the temporary copy multipart creates. A single response that fails halfway through streaming also cannot change its status code, because the headers have already been sent; the client sees a truncated body, so verify length or checksum on the client for important files.

5. Presigned URLs: take your server out of the data path

With a presigned URL the client sends the file straight to object storage. Your application only decides who may upload what, and signs a URL that says so. The service below does that signing with the AWS SDK’s S3Presigner. It signs locally from the access and secret key, so it opens no connection and the same code works for S3 and for MinIO (set app.s3.endpoint, with path-style access). Two decisions are made on the server, not by the client: the object key (a UUID) and the content type (checked against an allow-list in the controller).

public Upload forUpload(String contentType, Duration ttl) {
    String key = UUID.randomUUID().toString();
    PutObjectRequest put = PutObjectRequest.builder().bucket(bucket).key(key).contentType(contentType).build();
    PresignedPutObjectRequest signed = presigner.presignPutObject(r -> r.signatureDuration(ttl).putObjectRequest(put));
    Map<String, String> headers = new LinkedHashMap<>();
    for (Map.Entry<String, List<String>> h : signed.signedHeaders().entrySet()) {
        if (!h.getKey().equalsIgnoreCase("host")) {
            headers.put(h.getKey(), String.join(",", h.getValue()));
        }
    }
    return new Upload(key, signed.url().toString(), "PUT", headers, ttl.toSeconds());
}

Source: S3PresignService.java, PresignController.java

The test asks the endpoint for an upload URL, then tries to misuse it in several ways:

POST /presign/upload {"contentType":"image/png"} -> status=200
  method=PUT expiresInSeconds=300 headers={"content-type":"image/png"}
  url path:  http://<fake-s3>/uploads/4dfdf0af-141a-4d1b-9d35-3609a9385791
  query:     X-Amz-Algorithm=AWS4-HMAC-SHA256
  query:     X-Amz-Date=20261017T154013Z
  query:     X-Amz-SignedHeaders=content-type%3Bhost
  query:     X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261017%2Fus-east-1%2Fs3%2Faws4_request
  query:     X-Amz-Expires=300
  query:     X-Amz-Signature=4e5c56c29980cf521e477119788a0616c392deac4686df7259957af9f1445203
PUT with the signed Content-Type (image/png)     -> status=200
PUT the same URL again (presigned URLs are reusable) -> status=200
PUT with a different Content-Type (text/html)    -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
PUT with no Content-Type header                  -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
PUT after changing the last character of the key -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
PUT after raising X-Amz-Expires to 7 days         -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
GET presigned URL -> status=200 bytes match upload=true content-disposition=attachment; filename="holiday.png"
disallowed type: POST /presign/upload text/html -> status=400
PUT a URL signed for 1 second, sent after 2.2 seconds -> status=403 <Error><Code>AccessDenied</Code></Error>

Source: cap/04-presign.txt, produced by PresignedUrlTest.java

What the output shows, line by line:

  • X-Amz-SignedHeaders=content-type%3Bhost means the content type is part of the signature. Sending a different one, or none, fails with SignatureDoesNotMatch. That is why the server fixes it at signing time.
  • Changing the key, or lengthening X-Amz-Expires to 7 days, also breaks the signature. The URL cannot be edited by whoever holds it.
  • The same URL worked twice. A presigned URL is a bearer token: not single-use, valid for anyone who has it until it expires. Keep the lifetime short (5 minutes here) and do not log the URL.
  • A URL signed for one second was refused after 2.2 seconds with AccessDenied. That is the expiry check, which is separate from the signature check.
Going deeper: how this was tested without S3, and what that does not prove

This build environment has no Docker, so there was no MinIO container to upload to. Instead, FakeS3.java is a small in-process HTTP server that accepts PUT and GET on /{bucket}/{key} and recomputes the query-string signature the way the AWS documentation describes it: a canonical request, a string to sign, and a chain of four HMACs to derive the signing key. It does not use the AWS SDK for verification, so it is an independent check on the SDK’s output.

What that establishes: the URLs the SDK produced are consistent with the algorithm as I implemented it from the documentation, and the tamper, expiry and header cases above behave as described. What it does not establish: that a real S3 or MinIO server accepts them (I wrote both sides of that exchange), anything about bucket policies, CORS, or IAM permissions, or real network behaviour. Run the same module against a real bucket before relying on it; the only change needed is the four app.s3.* properties.

Going deeper: what a presigned PUT does not limit

This part comes from the AWS documentation and was not tested here. A presigned PUT signs the method, the key, the expiry and the listed headers, but it does not cap the size of the body. A client holding the URL can upload a file of any size the storage allows, so if size matters you need a different mechanism: a presigned POST with a policy that includes a content-length-range condition, or a lifecycle rule and a post-upload check that deletes oversized objects. A single PUT also has an upper size limit on S3 (5 GB at the time of writing), above which clients must use multipart upload, which means signing one URL per part.

The other pieces live outside this project: a browser doing a cross-origin PUT needs a CORS rule on the bucket (the CORS post covers the browser side), the application’s credentials need permission for the operations being signed, and nothing scans what was uploaded, so anything that opens or serves those files should treat them as untrusted.

Should you even do this? For small files and a modest rate, plain multipart straight to disk, with the limits, the generated filename and the pinned content type from sections 1 to 3, is enough, and it is much less to operate than an object store. Streaming (section 4) is the right default as soon as a file can be larger than a few megabytes. Presigned URLs earn their complexity when files are large or numerous, or when you want upload traffic off your application: you then own a bucket, its CORS and lifecycle rules, credentials, and cleanup of abandoned uploads. Also note what was and was not tested: everything above ran, except the presigned URLs against a real S3-compatible server, and the timings are single runs on a 2-CPU machine over loopback.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.