CWE-434: Unrestricted Upload of File with Dangerous Type - Java
Overview
Spring applications receive uploads as MultipartFile bound to a @PostMapping/@RequestParam handler. Two accessors are routinely misused as validation gates: MultipartFile.getContentType() and MultipartFile.getOriginalFilename(). Both are copied from the multipart request's part headers, which the client sets - Spring performs no verification that either value matches the file's actual bytes.
The reliable fix is to detect the real file type from its bytes with a content-sniffing library such as Apache Tika, generate the stored filename with UUID.randomUUID() instead of reusing getOriginalFilename(), and write to a directory outside anything Spring serves as a static resource (src/main/resources/static, webapp, or any path mapped by ResourceHandlerRegistry). Bound upload size with spring.servlet.multipart.max-file-size/max-request-size so oversized files are rejected before the application code runs.
Common Vulnerable Patterns
Trusting getContentType() and the getOriginalFilename() Extension
@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
// VULNERABLE - getContentType() is a client-supplied request header
Set<String> allowed = Set.of("image/png", "image/jpeg");
if (!allowed.contains(file.getContentType())) {
return ResponseEntity.badRequest().body("Invalid file type");
}
// VULNERABLE - the original filename is client-controlled and used as the save name
Path target = Paths.get("src/main/resources/static/uploads", file.getOriginalFilename());
file.transferTo(target);
return ResponseEntity.ok("uploaded");
}
// Attack: multipart part sends Content-Type: image/png and
// originalFilename="shell.jsp" but the body bytes are a JSP web shell.
// The declared content type is accepted at face value.
Why this is vulnerable: getContentType() reflects only what the client claimed in the multipart headers; it has no relationship to the bytes Spring is about to write to disk. getOriginalFilename() is equally client-controlled, so an attacker chooses both the extension checked (if any) and the exact name the file is saved under.
Saving Into a Static Resource Directory
// VULNERABLE - Spring Boot serves src/main/resources/static (and its packaged
// classpath:/static/ equivalent) directly over HTTP by default
Path target = Paths.get("src/main/resources/static/uploads", file.getOriginalFilename());
Why this is vulnerable: If the upload directory is one Spring Boot serves as a static resource, anything written there becomes reachable by URL. A servlet container that also executes .jsp files from that tree turns this into remote code execution once a JSP file is uploaded and requested.
Path Traversal via getOriginalFilename()
// VULNERABLE - string concatenation does not strip ".." segments
String path = uploadDir + "/" + file.getOriginalFilename();
file.transferTo(new File(path));
// Attack: originalFilename = "../../../../etc/cron.d/malicious"
Why this is vulnerable: Neither string concatenation nor a naive Paths.get(uploadDir, name) call rejects .. segments in name. Without normalizing the result and checking it stays under uploadDir, a crafted filename can resolve to a path outside the intended directory.
Secure Patterns
Content-Sniffed Validation with Apache Tika, Generated Filename, Storage Outside Webroot
import org.apache.tika.Tika;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.*;
import java.util.Set;
import java.util.UUID;
// SECURE - allowlist of real content types the endpoint accepts
private static final Set<String> ALLOWED_TYPES = Set.of("image/png", "image/jpeg");
// SECURE - outside src/main/resources/static, webapp, or any Spring-served path
private static final Path UPLOAD_DIR = Paths.get("/var/app-data/uploads");
private final Tika tika = new Tika();
public String storeUpload(MultipartFile file) throws Exception {
byte[] content = file.getBytes();
// SECURE - detect the real type from the bytes, not the client-supplied header
String detectedType = tika.detect(content);
if (!ALLOWED_TYPES.contains(detectedType)) {
throw new IllegalArgumentException("Unsupported file type: " + detectedType);
}
String extension = detectedType.equals("image/png") ? ".png" : ".jpg";
// SECURE - server-generated storage name; getOriginalFilename() is never
// used to build a filesystem path
String storedName = UUID.randomUUID() + extension;
Path target = UPLOAD_DIR.resolve(storedName).normalize();
// SECURE - defence-in-depth containment check even though storedName is
// fully server-generated
if (!target.startsWith(UPLOAD_DIR)) {
throw new IllegalArgumentException("Invalid target path");
}
Files.write(target, content, StandardOpenOption.CREATE_NEW);
return storedName;
}
Why this works: Apache Tika's detect() inspects the file's actual bytes (magic numbers, container structure) rather than trusting anything the client sent, so a mislabeled or forged Content-Type has no effect on the outcome. Because the stored name comes from UUID.randomUUID(), getOriginalFilename() - and any path traversal, null byte, or double-extension trick embedded in it - never reaches the filesystem API at all. Files.write(..., CREATE_NEW) fails rather than silently overwriting on a name collision, and the startsWith(UPLOAD_DIR) check confirms the resolved path stayed inside the intended directory.
Serving Uploaded Files Back Safely
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable String id, Authentication auth) throws IOException {
// SECURE - id is validated against the exact format the server generates
if (!id.matches("^[0-9a-fA-F-]{36}\\.(png|jpg)$")) {
return ResponseEntity.badRequest().build();
}
if (!uploadService.userCanAccess(auth, id)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Path path = UPLOAD_DIR.resolve(id).normalize();
if (!path.startsWith(UPLOAD_DIR) || !Files.exists(path)) {
return ResponseEntity.notFound().build();
}
Resource resource = new UrlResource(path.toUri());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment")
.header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
Why this works: Forcing Content-Disposition: attachment and application/octet-stream makes the browser download the file rather than render it inline, so a stored file cannot execute as HTML/SVG/script in a victim's browser even if something slipped past validation. Restricting id to the exact pattern the server generates, and re-normalizing and re-checking startsWith(UPLOAD_DIR), means the value can only ever resolve to a file inside the upload directory.
Framework-Specific Guidance
Spring Boot Multipart Configuration
# SECURE - application.properties: bound upload size before the file is fully buffered
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=5MB
Why this works: These properties reject oversized uploads at the servlet container / MultipartResolver level, so a large upload cannot exhaust server memory or disk before any application-level size check gets to run.
Testing
- Normal inputs: upload genuine PNG and JPEG files within the configured size limit; confirm both succeed and are retrievable through the download endpoint.
- Double extension: upload
invoice.pdf.jspwith real PDF bytes and with real JSP script bytes; confirm acceptance depends on the Tika-detected type, not the filename suffix. - MIME-type spoofing: submit
Content-Type: image/pngin the multipart part while the body is a script or executable; confirm rejection, since the header is never consulted. - Path traversal: set the original filename to
../../../../webapp/shell.jsp(and its URL-encoded form); confirm the stored path always resolves inside the configured upload directory. - Oversized file: upload past
spring.servlet.multipart.max-file-size; confirm the request is rejected with a413/MaxUploadSizeExceededExceptionbefore the handler body runs. - Rescan: re-run the originating scanner or integration test against the fixed endpoint to confirm the finding no longer reproduces.
Common Pitfalls
- Checking
getContentType()against an allowlist without also checking content: this only confirms the client sent a value from the expected list - it does nothing to confirm the bytes match, sincegetContentType()is set by the client regardless of what follows. - Normalizing the path but skipping the containment check: calling
.normalize()on the resolved path removes..segments syntactically, but without also assertingstartsWith(UPLOAD_DIR), a sufficiently deep traversal sequence can still normalize to a path outside the intended directory. - Running Tika on a subset of the file for large uploads:
tika.detect()on a truncated buffer can misclassify formats that store type-identifying structure later in the file; for large files, prefer detecting from a stream (tika.detect(InputStream, Metadata)) sized appropriately rather than an arbitrarily short prefix. The secure example above usesfile.getBytes(), which holds the whole upload in memory - fine under a 5 MB multipart cap, and the reason the cap has to be configured before this code is reached. - Reading a detected type as a statement about the whole file: detection identifies the prefix, not the contents. Measured on tika-core 4.0.0,
GIF89afollowed by a JSP body detects asimage/gif, and a valid PNG with a JSP payload appended afterIENDdetects asimage/png. Both are legitimate images by every signature check and both carry a script body that some other consumer may run. Keeping the allowlist to formats you re-encode (decode and re-emit the image, discarding everything that was not pixel data) is what actually removes the payload; detection alone only fixes the "wrong type entirely" case.
Dependencies and Installation
Content detection requires Apache Tika:
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>4.0.0</version>
</dependency>
tika-core 4.0.0 pulls in five compile-scope dependencies - commons-io, slf4j-api and three org.commonmark artifacts - where the 3.x line pulled only the first two. Maven and Gradle resolve all five, but a build that assembles its own classpath needs commons-io present or new Tika() fails with NoClassDefFoundError: org/apache/commons/io/input/TaggedInputStream; the commonmark artifacts serve Tika's Markdown handling and detection works with them absent.
4.0.0 is the current stable release, out on 2026-08-18, and supersedes the 4.0.0-beta-1 prerelease. The major step does not touch the API used above - new Tika(), detect(byte[]) and detect(InputStream, Metadata) all compile and behave the same, and 2.9.2, 3.3.2 and 4.0.0 return the same type for every case on this page when run on JDK 26, including the GIF89a-prefixed and IEND-appended files described under Common Pitfalls above. What does move is the bytecode target: 2.x ships Java 8 class files, 3.x Java 11 and 4.0.0 Java 17, so 4.x needs the Java 17 baseline this page already assumes. The 2.x line last shipped 2.9.4 in April 2025 and has had no release since, so a 2.x pin is worth moving off even where nothing is broken. Track the version through normal SCA/dependency-update tooling rather than pinning it indefinitely.