File Upload Security
Every attack a file upload enables and its defence: extension allowlists, real content-type detection with Tika, path traversal, polyglot files, SVG, and safe serving.
On this page
A file upload endpoint accepts arbitrary bytes from anyone and stores them. Every part of that sentence is an attack surface, and the defences are individually simple and collectively easy to leave incomplete.
Key Takeaways
- Detect the type from content, not from the extension or the declared header.
- Generate your own storage name; the client's filename is untrusted input.
- Store outside the web root and serve through an authorising controller.
- SVG and HTML uploads are stored XSS unless handled specifically.
- Set both multipart limits, and scan anything another user will download.
The attacks
| Attack | How | Defence |
|---|---|---|
| Web shell | shell.jsp uploaded into a served directory | Store outside the web root |
| Double extension | image.png.jsp | Detect content; derive the extension yourself |
| MIME spoofing | JSP body with Content-Type: image/png | Tika detection on the bytes |
| Path traversal | Filename ../../../etc/cron.d/job | Never use the client filename as a path |
| Stored XSS | SVG or HTML with script | Reject, sanitise, or force download |
| Zip bomb | 42KB expanding to petabytes | Cap expanded size and entry count |
| DoS | Enormous or endless upload | Multipart limits plus max-swallow-size |
| Malware distribution | Any file others download | Antivirus scan |
The pipeline
@Service
public class UploadService {
private static final Map<String, String> ALLOWED = Map.of(
"image/jpeg", ".jpg",
"image/png", ".png",
"image/webp", ".webp",
"application/pdf", ".pdf");
private final Tika tika = new Tika();
public StoredFile store(MultipartFile file, String uploadedBy) throws IOException {
if (file.isEmpty()) throw new BadRequestException("empty file");
// Detect from the leading bytes. The declared Content-Type and the
// extension are both client-controlled and mean nothing.
String detected;
try (InputStream in = file.getInputStream()) {
detected = tika.detect(in);
}
String extension = ALLOWED.get(detected);
if (extension == null) {
throw new UnsupportedMediaTypeException(detected);
}
// The client filename is kept as a DISPLAY LABEL only. It never
// becomes part of a path, and cleanPath strips traversal sequences.
String displayName = StringUtils.cleanPath(
Objects.requireNonNullElse(file.getOriginalFilename(), "upload"));
// Our key. UUID, our extension, date-prefixed for lifecycle rules.
String storageKey = "%s/%s%s".formatted(LocalDate.now(), UUID.randomUUID(), extension);
try (InputStream in = file.getInputStream()) {
if (!virusScanner.isClean(in)) {
securityEvents.record("malware-upload-blocked", uploadedBy, displayName);
throw new MaliciousFileException();
}
}
try (InputStream in = file.getInputStream()) {
storage.put(storageKey, in, file.getSize(), detected);
}
return metadata.save(new StoredFile(
storageKey, displayName, detected, file.getSize(), uploadedBy, Instant.now()));
}
}The extension comes from your map keyed on the detected type, not from the upload. That single decision eliminates double-extension attacks entirely — there is no path by which a client-supplied suffix reaches the filesystem.
Note that this reads the upload three times. MultipartFile.getInputStream() allows that because
Spring has already buffered the part — in memory below file-size-threshold, in a temporary file above
it — but it does mean the bytes sit on local disk before any check runs, and that temporary file needs
cleaning up on the failure paths as much as the happy one. For large uploads a single pass that tees
the stream into the scanner and the storage client together is worth the extra complexity.
Tika is not infallible, either. It reads magic bytes and container structure, and a file crafted to satisfy two formats at once can still pass detection. That is precisely why re-encoding, below, earns its place even after the type check has succeeded.
Limits
spring:
servlet:
multipart:
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
server:
tomcat:
# Bytes Tomcat reads before aborting an oversize request. Too small and
# the client sees a connection reset instead of your 413.
max-swallow-size: 25MB
connection-timeout: 20sSetting only max-file-size leaves you open to a request containing fifty files just under the limit.
Both values are required.
Images and archives
public byte[] reprocess(InputStream in) throws IOException {
BufferedImage image = ImageIO.read(in);
if (image == null) throw new BadRequestException("not a decodable image");
if (image.getWidth() > 4000 || image.getHeight() > 4000) {
throw new BadRequestException("image dimensions exceed the limit");
}
// Re-encoding strips EXIF — which routinely carries GPS coordinates — and
// destroys any polyglot payload hidden in the original container.
var output = new ByteArrayOutputStream();
ImageIO.write(Thumbnails.of(image).size(2000, 2000).asBufferedImage(), "jpg", output);
return output.toByteArray();
}Re-encoding is the strongest defence against polyglot files — a valid image that is also a valid archive or script. The re-encoded output contains only pixel data.
For archives, cap both the expanded size and the entry count before extracting anything, or a zip bomb fills the disk:
private static final long MAX_TOTAL = 500L * 1024 * 1024;
private static final int MAX_ENTRIES = 1000;
// Reject entries whose name escapes the target directory, and stop once the
// running total exceeds the cap rather than after extraction.
if (!entryPath.normalize().startsWith(targetDir.normalize())) {
throw new BadRequestException("archive entry escapes the target directory");
}Serving safely
@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
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.contentType()))
.contentLength(file.size())
// attachment stops the browser rendering an HTML or SVG payload
// in your origin, which would be stored XSS.
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(file.displayName(), StandardCharsets.UTF_8)
.build().toString())
.header("X-Content-Type-Options", "nosniff")
.cacheControl(CacheControl.noStore())
.body(new InputStreamResource(storage.open(file.storageKey())));
}Two headers do the work here. Content-Disposition: attachment prevents inline rendering, and
nosniff stops the browser second-guessing the declared type. Together they mean an uploaded HTML file
downloads rather than executes.
Better still, serve user content from a separate origin — usercontent.acme.com rather than
acme.com. Then even a rendered payload runs in an origin with no session and no access to your
application.
When the upload never reaches you
Presigned upload URLs invert all of this. If the browser PUTs directly to S3, none of the code above
ever sees the bytes, and every check has to move into an event handler that runs after the object
lands. That is a sound design — it keeps large transfers off your application entirely — but it changes
one thing that matters: the object exists and is addressable during the window before validation.
Have presigned uploads land in a quarantine bucket that nothing serves from, and promote to the serving bucket only once detection, scanning and re-encoding have all passed. Constrain the presigned URL itself as well: they can be issued with a content-length range and a fixed key prefix, which stops a client uploading a hundred gigabytes or writing over someone else's object.
What to take away
Detect the type from the bytes and derive the extension yourself. Never let a client filename touch a
path. Store outside the web root and authorise before serving. Re-encode images, cap archive
expansion, scan anything others will download, and serve with attachment and nosniff — ideally
from a separate origin.
Frequently Asked Questions
Is checking the file extension enough?
Why is allowing SVG uploads dangerous?
Where should uploaded files be stored?
Related tutorials
- XSS PreventionStopping cross-site scripting: the three XSS types, why encoding must be context-aware, Thymeleaf escaping, OWASP Java Encoder, nonce-based CSP and sanitising rich text.
- SSRF PreventionStopping server-side request forgery: why cloud metadata endpoints are the prize, validating URLs correctly, defeating DNS rebinding, and egress controls as a second layer.
- SQL Injection PreventionHow SQL injection actually works, why parameterised queries stop it, the JPA and JdbcTemplate patterns that are safe, the ones that are not, and how to test for it.
- Insecure DeserialisationWhy readObject on untrusted data is remote code execution, how gadget chains work, ObjectInputFilter as a mitigation, and the Jackson polymorphic typing configuration to avoid.