Decide the type from the bytes, and never let the client pick the name. Check getSize() against the feature's limit, require the extension, getContentType() and the PNG or JPEG signature to agree, write under a UUID outside the static locations, and serve with ContentDisposition.attachment() and X-Content-Type-Options: nosniff. Never pass getOriginalFilename() to Path.resolve().
- Find every MultipartFile (and Part) handler and where the file goes: transferTo() or Files.write() with getOriginalFilename() in the path, a folder under spring.web.resources.static-locations, or a check that only looks at getContentType().
- Keep spring.servlet.multipart.max-file-size and max-request-size as low as the largest upload in the app allows (1MB and 10MB by default), and check getSize() against this feature's own limit, answering 413 (HttpStatus.CONTENT_TOO_LARGE).
- Accept the file only if StringUtils.getFilenameExtension(getOriginalFilename()), lower-cased, is on your allowlist, getContentType() is the type that extension names, and getBytes() starts with that type's signature (89 50 4E 47 0D 0A 1A 0A for PNG, FF D8 FF for JPEG). Answer 415 otherwise.
- Write it with Files.write(storageDir.resolve(UUID.randomUUID() + extension), bytes, StandardOpenOption.CREATE_NEW), in a folder outside the static locations, so a name collision fails instead of replacing a file.
- Serve it from a GET mapping that accepts only names your code generates and returns a ResponseEntity with the verified MediaType, Content-Disposition from ContentDisposition.attachment() and X-Content-Type-Options: nosniff, or serve uploads from a separate origin.
- Add regression tests against a real server (webEnvironment = RANDOM_PORT) with this card's payloads: an HTML page and an SVG with script, HTML named avatar.png, avatar.png.html, a PNG that is also a page, ../ and absolute file names, a file one byte over the limit, and two users uploading the same name.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
@Override
public URI save(MultipartFile file) throws IOException {
String name = file.getOriginalFilename();
file.transferTo(uploadDir.resolve(name));
// Spring serves the web root as static resources, so the file is public as soon as it is written.
return URI.create("/uploads/" + UriUtils.encodePathSegment(name, StandardCharsets.UTF_8));
}With IMPL=vulnerable, 9 of 13 tests fail (9 exploit tests).
The fix: check and store the upload
private static final long MAX_BYTES = 512 * 1024;
private record ImageType(String mediaType, String extension, byte[] signature) {}
private static final ImageType PNG = new ImageType("image/png", ".png", HexFormat.of().parseHex("89504e470d0a1a0a"));
private static final ImageType JPEG = new ImageType("image/jpeg", ".jpg", HexFormat.of().parseHex("ffd8ff"));
/** The only types this feature needs, keyed by the extensions that may name them. */
private static final Map<String, ImageType> TYPES = Map.of("png", PNG, "jpg", JPEG, "jpeg", JPEG);
@Override
public URI save(MultipartFile file) throws IOException {
// spring.servlet.multipart.max-file-size caps every upload in the app (1MB by default);
// this feature's own limit is smaller.
if (file.getSize() > MAX_BYTES) {
throw new ResponseStatusException(HttpStatus.CONTENT_TOO_LARGE, "avatars must be at most 512 KiB");
}
// getFilenameExtension() returns the last extension only: "html" for avatar.png.html.
String extension = StringUtils.getFilenameExtension(file.getOriginalFilename());
ImageType type = extension == null ? null : TYPES.get(extension.toLowerCase(Locale.ROOT));
byte[] content = file.getBytes();
if (type == null || !type.mediaType().equalsIgnoreCase(file.getContentType()) || !startsWith(content, type.signature())) {
throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "avatars must be PNG or JPEG images");
}
// A name the server makes up, in a folder Spring does not serve; CREATE_NEW never overwrites.
String name = UUID.randomUUID() + type.extension();
Files.write(storageDir.resolve(name), content, StandardOpenOption.CREATE_NEW);
return URI.create("/avatars/" + name);
}
private static boolean startsWith(byte[] content, byte[] signature) {
return content.length >= signature.length
&& Arrays.equals(content, 0, signature.length, signature, 0, signature.length);
}With IMPL=fixed, 13 of 13 tests pass.
The fix: serve it as a download
private static final Pattern STORED_NAME = Pattern.compile("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}\\.(png|jpg)");
@Override
public ResponseEntity<byte[]> serve(String name) throws IOException {
// Only names this code generates; anything else, ../ included, is simply not found.
if (!STORED_NAME.matcher(name).matches() || !Files.isRegularFile(storageDir.resolve(name))) {
return ResponseEntity.notFound().build();
}
// attachment: the browser downloads the file instead of rendering it; nosniff: it must not
// second-guess the Content-Type.
return ResponseEntity.ok()
.contentType(name.endsWith(".png") ? MediaType.IMAGE_PNG : MediaType.IMAGE_JPEG)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(name).build().toString())
.header("X-Content-Type-Options", "nosniff")
.body(Files.readAllBytes(storageDir.resolve(name)));
}With IMPL=fixed, 13 of 13 tests pass.
A fix that looks safe but is not
private static final Set<String> ALLOWED_TYPES = Set.of("image/png", "image/jpeg");
private static final long MAX_BYTES = 512 * 1024;
@Override
public URI save(MultipartFile file) throws IOException {
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_TYPES.contains(contentType)) {
throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "avatars must be PNG or JPEG images");
}
if (file.getSize() > MAX_BYTES) {
throw new ResponseStatusException(HttpStatus.CONTENT_TOO_LARGE, "avatars must be at most 512 KiB");
}
String extension = StringUtils.getFilenameExtension(file.getOriginalFilename());
String name = UUID.randomUUID() + (extension == null ? "" : "." + extension.toLowerCase(Locale.ROOT));
file.transferTo(uploadDir.resolve(name));
return URI.create("/uploads/" + name);
}With IMPL=wrong, 3 of 13 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: what an attacker sends instead of a picture.
@Test
void exploitHtmlPageWithAScriptIsRefused() throws Exception {
assertRefused(upload("profile.html", "text/html", HTML), 415);
}
@Test
void exploitSvgWithAScriptIsRefused() throws Exception {
assertRefused(upload("avatar.svg", "image/svg+xml", SVG), 415);
}
@Test
void exploitHtmlNamedPngAndDeclaredImagePngIsRefused() throws Exception {
assertRefused(upload("avatar.png", "image/png", HTML), 415);
}
@Test
void exploitDoubleExtensionIsRefusedEvenWhenTheBytesStartLikeAPng() throws Exception {
assertRefused(upload("avatar.png.html", "image/png", POLYGLOT), 415);
}
@Test
void exploitPngThatIsAlsoAnHtmlPageIsServedOnlyAsAnImageDownload() throws Exception {
Upload upload = upload("avatar.png", "image/png", POLYGLOT);
assertThat(upload.status()).isEqualTo(201);
HttpResponse<byte[]> served = get(upload.url());
assertThat(served.headers().firstValue("Content-Type")).hasValueSatisfying(type -> assertThat(type).startsWith("image/png"));
assertThat(served.headers().firstValue("X-Content-Type-Options")).hasValue("nosniff");
assertThat(served.headers().firstValue("Content-Disposition").orElse("inline")).startsWith("attachment");
}
@Test
void exploitDotDotSlashInTheFileNameCannotPutTheFileOutsideTheUploadFolder() throws Exception {
upload("../escaped.png", "image/png", PNG);
assertThat(filesOutsideUploadFolders()).isEmpty();
}
@Test
void exploitAbsoluteFileNameCannotPutTheFileOutsideTheUploadFolder() throws Exception {
upload(TMP.resolve("escaped.png").toString(), "image/png", PNG);
assertThat(filesOutsideUploadFolders()).isEmpty();
}
@Test
void exploitFileOneByteOverTheLimitIsRefusedWith413() throws Exception {
assertRefused(upload("avatar.png", "image/png", Arrays.copyOf(PNG, LIMIT_BYTES + 1)), 413);
}
@Test
void exploitUploadCannotReplaceAnotherUsersFileByReusingItsName() throws Exception {
Upload alice = upload("avatar.png", "image/png", PNG);
Upload mallory = upload("avatar.png", "image/png", OTHER_PNG);
assertThat(alice.status()).isEqualTo(201);
assertThat(mallory.status()).isEqualTo(201);
assertThat(get(alice.url()).body()).as("Alice's URL must still serve her own image").isEqualTo(PNG);
}- getOriginalFilename() is the client's string. With Tomcat it arrived as ../escaped.png and as an absolute path, and Path.resolve() returns an absolute argument as it is, so the vulnerable transferTo() wrote outside the upload folder. Spring's own Javadoc says not to use this name directly.
- getContentType() is the Content-Type of the multipart part, which the client writes. A script can send image/png with an HTML body, which is how avatar.png.html gets past the wrong implementation.
- Spring's static resource handling serves anything under spring.web.resources.static-locations with a Content-Type taken from the extension and no Content-Disposition, so <random>.html is served as text/html. Spring Security's default headers add X-Content-Type-Options: nosniff, which does not stop a response declared text/html from rendering.
- spring.servlet.multipart.max-file-size (1MB) and max-request-size (10MB) apply to the whole app; a probe got 413 from MaxUploadSizeExceededException at 1,048,577 bytes. Raising them for one large-file feature raises them for every endpoint, so keep per-feature limits in code.
- MockMvc's multipart() hands the controller a MockMultipartFile directly: Tomcat's parsing and the multipart size limits never run (a probe uploaded 2 MiB through MockMvc with no error). This card's tests use a real server on a random port for that reason.
- StringUtils.getFilenameExtension() returns only the last extension ("html" for avatar.png.html) and null when there is none, which is what the fixed check needs. Checks such as name.contains(".png") accept avatar.png.html.
- A file signature proves how a file starts, not that it is harmless: the polyglot test is a valid PNG that is also a working page. The serving headers are what keep it from rendering; re-encode images with a maintained library if you also need to strip trailing data and metadata.
- Content-Disposition: attachment does not stop an <img> element from showing the avatar; it makes a direct visit to the URL download the file instead of rendering it.
- The upload endpoint here has no sign-in. Behind cookie authentication, keep Spring Security's CSRF protection on for it: a multipart form post is a cross-site request like any other (see the CSRF card).
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs file-upload/java-spring does this for you).
- Against your own endpoint, upload an HTML file named avatar.png with Content-Type image/png and a .svg with a <script> element; both must be refused with nothing stored. Open the URL of an accepted avatar and check for Content-Disposition: attachment and X-Content-Type-Options: nosniff.
- Do not count on static analysis here: the Semgrep rules above found nothing in any implementation, and CodeQL has no sink model for MultipartFile.transferTo(). The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/default, p/security-audit, p/owasp-top-ten | No findings in the seven files. The semgrep-rules repository (develop at 9bbf021) has no rule for MultipartFile.getOriginalFilename(), getContentType() or transferTo().Checked: Semgrep 1.180.0 (CE); semgrep scan --config p/default --config p/security-audit --config p/owasp-top-ten src/main/java |
| Semgrep | java.spring.security.injection.tainted-file-path.tainted-file-path | No findings. The rule is marked interfile, so Semgrep CE does not follow the @RequestParam MultipartFile from AvatarController into the store, and its sinks (new File, Paths.get, FileInputStream, FileOutputStream and similar) do not include Path.resolve() or MultipartFile.transferTo().Checked: Semgrep 1.180.0 (CE); semgrep --config r/java.spring.security.injection.tainted-file-path.tainted-file-path src/main/java (Ran 1 rule on 7 files) |
| CodeQL | java/path-injection | From ext/org.springframework.web.multipart.model.yml: getOriginalFilename() and getContentType() are remote sources, and Path.resolve() carries taint (ext/java.nio.file.model.yml). No model makes MultipartFile.transferTo() a path-injection sink, so the vulnerable uploadDir.resolve(name) passed to transferTo() would probably not be reported. Files.write() is a sink, but the fixed code passes it a generated name.Checked: github/codeql main at b5b8165; not executed |
| Find Security Bugs (SpotBugs plugin) | FILE_UPLOAD_FILENAMETainted filename read | Its description covers the file name from the FileUpload API, with ../ and a NUL-byte double extension as examples, and recommends generating the file name. Whether it recognises MultipartFile.getOriginalFilename() was not checked.Checked: Bug pattern listed on the Find Security Bugs site; not executed |
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC on Tomcat 11.0.26) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.apache.tomcat.embed:tomcat-embed-core (overrides Boot's 11.0.24) | 11.0.26 |
| tools.jackson:jackson-bom (overrides Boot's 3.1.5) | 3.1.7 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
| com.jayway.jsonpath:json-path (managed by Spring Boot; reads seed.json in the test) | 2.10.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the file-upload/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs file-upload/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- java OpenJDK 21.0.12.1 (Homebrew)
- maven 3.9.16
- springBoot 4.1.1
- springFramework 7.0.9
- tomcat 11.0.26
- junitJupiter 6.0.3
- Matrix
- fixed: 13 of 13 tests pass; vulnerable: 9 of 13 tests fail (9 exploit tests); wrong: 3 of 13 tests fail (3 exploit tests)
- Test results
- IMPL=fixed 13/13 pass; IMPL=vulnerable 9/13 fail, all exploit tests (HTML, SVG, HTML named avatar.png, avatar.png.html, the polyglot's headers, ../ and the absolute name both writing outside the upload folder, one byte over the limit, the reused name); IMPL=wrong 3/13 fail, all exploit tests (HTML named avatar.png, avatar.png.html served as text/html, the polyglot's headers). The tests run against Tomcat on a random port; the 512 KiB + 1 file fits under Boot's 1MB default, so only the code's own limit can refuse it. The same results came from a copy installed with an empty Maven repository (42 MB downloaded) and run in a no-network sandbox.
- Dependency freshness
- Spring Boot 4.1.1 is the current 4.1.x GA on Maven Central (4.2.0-M2 is a milestone). Tomcat 11.0.26 (2026-09-15, the latest 11.0.x) and Jackson 3.1.7 override the versions Boot manages, as in the other Java cards, because those have published advisories. .mvn/maven.config sets file-based resolver locking so concurrent Maven runs can share the local repository.
- Scanners
- Semgrep 1.180.0 from a throwaway venv (pip install semgrep==1.180.0), not a card dependency; CodeQL read from source, not executed.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP File Upload Cheat Sheet
- OWASP ASVS 5.0.0, V5 File Handling
- Spring Framework 7 API: MultipartFile
- Spring Framework 7 API: ContentDisposition
- Spring Boot: Servlet multipart properties (spring.servlet.multipart.*)
- Spring Boot: Static content
- Spring guide: Uploading Files
- WHATWG MIME Sniffing: matching an image type pattern
- Find Security Bugs: FILE_UPLOAD_FILENAME
Attack patterns: