Skip to content
JavaAgentic

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

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.

Intermediate6 min readUpdated
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

AttackHowDefence
Web shellshell.jsp uploaded into a served directoryStore outside the web root
Double extensionimage.png.jspDetect content; derive the extension yourself
MIME spoofingJSP body with Content-Type: image/pngTika detection on the bytes
Path traversalFilename ../../../etc/cron.d/jobNever use the client filename as a path
Stored XSSSVG or HTML with scriptReject, sanitise, or force download
Zip bomb42KB expanding to petabytesCap expanded size and entry count
DoSEnormous or endless uploadMultipart limits plus max-swallow-size
Malware distributionAny file others downloadAntivirus scan

The pipeline

Each gate rejects before the bytes become durable, and the client's filename never influences a path.
UploadService.java
@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

application.yml
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: 20s

Setting only max-file-size leaves you open to a request containing fifty files just under the limit. Both values are required.

Images and archives

ImageReprocessing.java
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:

SafeUnzip.java
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

DownloadController.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
 
    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 originusercontent.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?
No. The extension is part of a filename the client chose, so it says nothing about the content. Detect the real type from the leading bytes with Tika and compare that against your allowlist. Treat the extension and the declared Content-Type as hints, never as controls.
Why is allowing SVG uploads dangerous?
An SVG is XML and can contain script elements and event handlers. Served from your origin with an image content type, it executes as a page in your security context — stored XSS delivered as a picture. Either reject SVG, sanitise it as HTML, or serve it from a separate origin with Content-Disposition attachment.
Where should uploaded files be stored?
Object storage, outside any directory the web server serves. Serving files from a filesystem path derived from user input invites path traversal, and a file inside the web root can be requested directly, bypassing every authorisation check in your application.

Related tutorials