- Spring
Spring Bootファイルアップロード|MultipartFile実装コード全文
Spring Boot のファイルアップロードは、コントローラーの引数に MultipartFile を書くだけで受け取れます。既定の上限は1ファイル 1MB・1リクエスト 10MB で、spring.servlet.multipart.* のプロパティで変更します。 受け取ったあとの「名前を付け直す」「種類を確かめる」「保存先の外に出さない」「上限を超えたときに分かりやすく返す」までを含めて、ひとまとまりの実装になります。
この記事では、単一・複数・JSON と同時の3つの受け取り方、保存用の Service、上限超過を 413 で返す例外処理、MockMvc のテストまでのコード全文と、設定・メソッド・判断基準の一覧表をまとめます。
確認環境:Spring Boot 4.1.1/Spring Framework 7.0.9 の公式リファレンスと API 仕様書で確認(確認日 2026年10月7日)。Java 単体で再現できる部分は OpenJDK 21.0.10(Linux)で実行しています。
Spring Bootファイルアップロードの早見表
| やりたいこと | 書き方 |
|---|---|
| 1つ受け取る | @RequestParam("file") MultipartFile file |
| 複数受け取る | @RequestParam("files") List<MultipartFile> files |
| JSON と一緒に受け取る | @RequestPart("meta") Meta meta + @RequestPart("file") MultipartFile file |
| 1ファイルの上限を変える | spring.servlet.multipart.max-file-size(既定 1MB) |
| 1リクエストの上限を変える | spring.servlet.multipart.max-request-size(既定 10MB) |
| 上限超過を捕まえる | @ExceptionHandler(MaxUploadSizeExceededException.class) |
| 保存名を決める | UUID.randomUUID() + 判定した種類の拡張子 |
| テストする | MockMultipartFile + multipart("/path").file(...) |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Spring Bootファイルアップロード|MultipartFile実装コード全文」
全体の構成
| ファイル | 役割 |
|---|---|
application.yml |
サイズ上限、保存先ディレクトリ |
FileUploadController |
単一・複数・JSON 同時の3つの受け口 |
FileStorageService |
種類の判定、名前の付け直し、保存 |
FileTypeDetector |
先頭バイトから種類を判定 |
UploadExceptionHandler |
上限超過を 413、内容の不備を 400 で返す |
FileUploadControllerTests |
MockMvc のテスト |
Spring Boot のリファレンスでは、ファイルアップロードにはサーブレットの Part API を使い、Apache Commons FileUpload のような追加の依存関係ではなくコンテナ組み込みのマルチパート対応を使うことが勧められています。 追加のライブラリは要りません。
依存関係
Spring Boot 4.0 の移行ガイドのとおり、Web 用スターターは spring-boot-starter-webmvc、テスト用は spring-boot-starter-webmvc-test です。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
設定ファイル(application.yml)
spring:
servlet:
multipart:
max-file-size: 5MB # 1ファイルの上限(既定 1MB)
max-request-size: 20MB # 1リクエスト全体の上限(既定 10MB)
file-size-threshold: 0 # この大きさを超えたらディスクに書き出す(既定 0)
mvc:
problemdetails:
enabled: true # Spring MVC の例外を RFC 9457 の形式で返す
app:
upload:
dir: /var/app/uploads # 保存先(公開ディレクトリの外に置く)
受け取るコントローラー(単一・複数・JSON同時)
package com.example.demo.upload;
public record StoredFile(String storedName, String originalName, String contentType, long size) {
}
package com.example.demo.upload;
public record DocumentMeta(String title, String note) {
}
package com.example.demo.upload;
import java.util.List;
public record DocumentResponse(DocumentMeta meta, StoredFile file) {
public record Batch(int count, List<StoredFile> files) {
}
}
package com.example.demo.upload;
import java.net.URI;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/files")
public class FileUploadController {
private final FileStorageService storage;
public FileUploadController(FileStorageService storage) {
this.storage = storage;
}
// 1. 単一ファイル:フォームの項目名 file で受け取る
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<StoredFile> upload(@RequestParam("file") MultipartFile file) {
StoredFile stored = this.storage.store(file);
return ResponseEntity
.created(URI.create("/api/files/" + stored.storedName()))
.body(stored);
}
// 2. 複数ファイル:同じ項目名 files を繰り返して送る
@PostMapping(path = "/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public DocumentResponse.Batch uploadMany(@RequestParam("files") List<MultipartFile> files) {
List<StoredFile> stored = files.stream()
.map(this.storage::store)
.toList();
return new DocumentResponse.Batch(stored.size(), stored);
}
// 3. JSON と同時:meta パート(application/json)と file パートを1回で受け取る
@PostMapping(path = "/with-meta", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public DocumentResponse uploadWithMeta(
@RequestPart("meta") DocumentMeta meta,
@RequestPart("file") MultipartFile file) {
StoredFile stored = this.storage.store(file);
return new DocumentResponse(meta, stored);
}
}
| 受け取り方 | 注釈 | 送る側の形 |
|---|---|---|
| 単一 | @RequestParam("file") |
file という名前のパートを1つ |
| 複数 | @RequestParam("files") + List |
files という名前のパートを複数 |
| JSON 同時 | @RequestPart を2つ |
meta パートの Content-Type を application/json にする |
@RequestPart("meta") は、パートの Content-Type を見て本文をオブジェクトに変換します。送る側で meta パートに application/json を付けるのがポイントです。
動かして試すときの curl
# 1. 単一
curl -i -F "file=@photo.png" http://localhost:8080/api/files
# 2. 複数(同じ項目名を繰り返す)
curl -i -F "files=@photo.png" -F "files=@manual.pdf" http://localhost:8080/api/files/batch
# 3. JSON と同時(meta パートに type=application/json を付ける)
curl -i \
-F 'meta={"title":"議事録","note":"10月分"};type=application/json' \
-F "file=@minutes.pdf" \
http://localhost:8080/api/files/with-meta
ブラウザから送る場合の最小のフォームです。
<form action="/api/files" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept="image/png,image/jpeg,application/pdf">
<button type="submit">アップロード</button>
</form>
保存用の Service(名前を付け直し、保存先の外に出さない)
MultipartFile#getOriginalFilename() の値は送る側が決めるもので、Spring Framework の API 仕様書にも「そのまま使わず、一意の名前を生成し、元の名前は参照用に別に保存するのが望ましい」と書かれています。この Service では次の順で処理します。
- 空のファイルを受け付けない
- 先頭バイトから種類を判定し、許可した種類だけ通す
UUIDで保存名を作り、拡張子は判定した種類から決めるPath.normalize()とstartsWith()で、保存先ディレクトリの中に収まることを確かめる- 元のファイル名は表示用の情報として返すだけにする
package com.example.demo.upload;
public class InvalidUploadException extends RuntimeException {
public InvalidUploadException(String message) {
super(message);
}
}
package com.example.demo.upload;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
public class FileStorageService {
/** 許可する種類と、保存時に付ける拡張子。 */
private static final Map<String, String> ALLOWED = Map.of(
"image/png", ".png",
"image/jpeg", ".jpg",
"application/pdf", ".pdf");
private final Path root;
public FileStorageService(@Value("${app.upload.dir}") String dir) {
this.root = Path.of(dir).toAbsolutePath().normalize();
}
public StoredFile store(MultipartFile file) {
if (file.isEmpty()) {
throw new InvalidUploadException("ファイルが選ばれていないか、中身が空です");
}
// 種類は送られてきた名前や Content-Type ではなく、中身の先頭バイトで判定する
String type = detectType(file);
String extension = ALLOWED.get(type);
if (extension == null) {
throw new InvalidUploadException("受け付けていない種類のファイルです");
}
// 保存名はサーバー側で作る
String storedName = UUID.randomUUID() + extension;
Path target = this.root.resolve(storedName).normalize();
if (!target.startsWith(this.root)) {
throw new InvalidUploadException("保存先を決められませんでした");
}
try {
Files.createDirectories(this.root);
try (InputStream in = file.getInputStream()) {
Files.copy(in, target);
}
} catch (IOException ex) {
throw new UncheckedIOException("ファイルを保存できませんでした", ex);
}
// 元の名前は表示用。パスの組み立てには使わない
return new StoredFile(storedName, file.getOriginalFilename(), type, file.getSize());
}
private String detectType(MultipartFile file) {
try (InputStream in = file.getInputStream()) {
return FileTypeDetector.detect(in)
.orElseThrow(() -> new InvalidUploadException("ファイルの種類を判定できませんでした"));
} catch (IOException ex) {
throw new UncheckedIOException("ファイルを読み取れませんでした", ex);
}
}
}
package com.example.demo.upload;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
public final class FileTypeDetector {
/** 種類 → 先頭バイト(シグネチャ)。 */
private static final Map<String, byte[]> SIGNATURES = new LinkedHashMap<>();
static {
SIGNATURES.put("image/png", new byte[] {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
SIGNATURES.put("image/jpeg", new byte[] {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
SIGNATURES.put("image/gif", new byte[] {'G', 'I', 'F', '8'});
SIGNATURES.put("application/pdf", new byte[] {'%', 'P', 'D', 'F', '-'});
SIGNATURES.put("application/zip", new byte[] {'P', 'K', 0x03, 0x04});
}
private FileTypeDetector() {
}
/** ストリームの先頭 12 バイトを読んで種類を返す。 */
public static Optional<String> detect(InputStream in) throws IOException {
byte[] head = in.readNBytes(12);
// WebP は RIFF????WEBP の形
if (head.length >= 12 && startsWith(head, "RIFF".getBytes(StandardCharsets.US_ASCII))
&& Arrays.equals(Arrays.copyOfRange(head, 8, 12), "WEBP".getBytes(StandardCharsets.US_ASCII))) {
return Optional.of("image/webp");
}
for (Map.Entry<String, byte[]> e : SIGNATURES.entrySet()) {
if (startsWith(head, e.getValue())) {
return Optional.of(e.getKey());
}
}
return Optional.empty();
}
private static boolean startsWith(byte[] data, byte[] prefix) {
return data.length >= prefix.length
&& Arrays.equals(Arrays.copyOf(data, prefix.length), prefix);
}
}
上限超過を 413 の ProblemDetail で返す
MaxUploadSizeExceededException は、アップロードが許可された上限を超えたときに投げられる例外です。getMaxUploadSize() で上限値(分からないときは -1)を取れます。@RestControllerAdvice で受けて、RFC 9457 の形式(ProblemDetail)で 413 を返します。
package com.example.demo.upload;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class UploadExceptionHandler {
// 上限超過 → 413
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ProblemDetail handleTooLarge(MaxUploadSizeExceededException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatusCode.valueOf(413),
"アップロードできる大きさの上限を超えています。ファイルを小さくして、もう一度お試しください。");
problem.setTitle("ファイルが大きすぎます");
problem.setProperty("maxUploadSize", ex.getMaxUploadSize());
return problem;
}
// 空のファイル、受け付けていない種類 → 400
@ExceptionHandler(InvalidUploadException.class)
public ProblemDetail handleInvalid(InvalidUploadException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
problem.setTitle("アップロードの内容を確認してください");
return problem;
}
}
@ExceptionHandler のメソッドから ProblemDetail を返すと、その status が HTTP ステータスになり、application/problem+json の形で応答されます。setProperty で足した値(ここでは maxUploadSize)は、標準の項目に加えた独自の項目として出力されます。
MockMvc のテスト(MockMultipartFile)
package com.example.demo.upload;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@WebMvcTest(FileUploadController.class)
class FileUploadControllerTests {
private static final byte[] PNG_HEAD = {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13};
@Autowired
MockMvc mvc;
@MockitoBean
FileStorageService storage;
@Test
void uploadSingleFile() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "photo.png", "image/png", PNG_HEAD);
given(this.storage.store(any()))
.willReturn(new StoredFile("11111111-2222-3333-4444-555555555555.png", "photo.png", "image/png", 12));
this.mvc.perform(multipart("/api/files").file(file))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/files/11111111-2222-3333-4444-555555555555.png"))
.andExpect(jsonPath("$.originalName").value("photo.png"));
}
@Test
void uploadManyFiles() throws Exception {
MockMultipartFile first = new MockMultipartFile("files", "a.png", "image/png", PNG_HEAD);
MockMultipartFile second = new MockMultipartFile("files", "b.png", "image/png", PNG_HEAD);
given(this.storage.store(any()))
.willReturn(new StoredFile("stored.png", "a.png", "image/png", 12));
this.mvc.perform(multipart("/api/files/batch").file(first).file(second))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(2));
}
@Test
void uploadWithJsonPart() throws Exception {
MockMultipartFile meta = new MockMultipartFile("meta", "", "application/json",
"{\"title\":\"議事録\",\"note\":\"10月分\"}".getBytes(StandardCharsets.UTF_8));
MockMultipartFile file = new MockMultipartFile("file", "minutes.png", "image/png", PNG_HEAD);
given(this.storage.store(any()))
.willReturn(new StoredFile("stored.png", "minutes.png", "image/png", 12));
this.mvc.perform(multipart("/api/files/with-meta").file(meta).file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.title").value("議事録"));
}
@Test
void tooLargeIsReportedAs413() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "big.png", "image/png", PNG_HEAD);
given(this.storage.store(any()))
.willThrow(new MaxUploadSizeExceededException(5L * 1024 * 1024));
this.mvc.perform(multipart("/api/files").file(file))
.andExpect(status().is(413))
.andExpect(jsonPath("$.title").value("ファイルが大きすぎます"))
.andExpect(jsonPath("$.maxUploadSize").value(5242880));
}
@Test
void invalidTypeIsReportedAs400() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "memo.txt", "text/plain",
"hello".getBytes(StandardCharsets.UTF_8));
given(this.storage.store(any()))
.willThrow(new InvalidUploadException("受け付けていない種類のファイルです"));
this.mvc.perform(multipart("/api/files").file(file))
.andExpect(status().isBadRequest());
}
}
テストを書くときに押さえておきたい点が2つあります。
- MockMvc のファイルアップロードは
MockMultipartHttpServletRequestを使い、実際のマルチパート解析を行いません(Spring Framework リファレンスの記載)。そのためmax-file-sizeの上限そのものは MockMvc では働かず、上のテストでは例外を投げさせて「例外が来たら 413 の形で返ること」を確かめています。上限の値が効いていることは、アプリを起動してcurlで大きなファイルを送って確かめます。 @WebMvcTestは@Controllerと@ControllerAdviceを読み込みますが、通常の@Component(ここではFileStorageService)は読み込みません。@MockitoBeanで差し替えます。
spring.servlet.multipart の設定一覧
Spring Boot 4.1.1 の MultipartProperties(接頭辞 spring.servlet.multipart)の項目です。
| プロパティ | 意味 | 既定 |
|---|---|---|
max-file-size |
1ファイルの上限 | 1MB |
max-request-size |
multipart/form-data のリクエスト全体の上限 |
10MB |
file-size-threshold |
この大きさを超えたファイルをディスクに書き出す | 0 |
location |
受け取り途中のデータを置くディレクトリ | 一時ディレクトリ |
このほか MultipartProperties には enabled、resolveLazily、strictServletCompliance の項目があります。
値の書き方は次のとおりです。
| 書き方 | 意味 |
|---|---|
5MB、512KB |
読みやすい単位つきの指定 |
5242880 |
バイト数の整数での指定 |
-1 |
上限なし(リファレンスの例は max-file-size) |
前段のサーバーの上限との対応
リクエストは Spring に届く前に、前段のサーバーや組み込みコンテナの上限も通ります。外側ほど大きく(または同じに)そろえると、どこで断られたのかが分かりやすくなります。
| 層 | 設定 | 既定 | 役割 |
|---|---|---|---|
| nginx | client_max_body_size |
1m | リクエスト本文の上限。超えると 413。0 で確認なし |
| Tomcat | maxSwallowSize |
2MiB | 中断したアップロードの残りを読み捨てる量の上限 |
| Tomcat | maxPartCount |
50 | multipart/form-data のパート数の上限 |
| Tomcat | maxPartHeaderSize |
512 | パート1つあたりのヘッダーのバイト数の上限 |
| Spring Boot | max-request-size |
10MB | リクエスト全体の上限 |
| Spring Boot | max-file-size |
1MB | 1ファイルの上限 |
Tomcat の値は Tomcat 11.0 の設定リファレンス(HTTP Connector の属性)、nginx の値は nginx の公式ドキュメントによるものです(どちらも確認日 2026年10月7日)。
そろえ方の例です。
# nginx:Spring Boot の max-request-size(20MB)以上にする
server {
listen 80;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
| 起きること | 見直すところ |
|---|---|
| nginx の既定のエラーページで 413 が返る | client_max_body_size が Spring 側の上限より小さい |
| 2MB 程度のファイルで上限超過になる | max-file-size が既定の 1MB のまま |
| 1つずつは小さいのに、まとめて送ると上限超過になる | max-request-size の合計の上限 |
| ファイルを数十個まとめて送ると受け付けられない | Tomcat の maxPartCount(既定 50。フォームの他の項目もパートに数える) |
| 上限超過のときに、画面側で応答を受け取れないことがある | Tomcat の maxSwallowSize(残りを読み捨てられる量) |
MultipartFile の主要メソッド一覧
| メソッド | 返すもの | 使いどころと注意 |
|---|---|---|
getName() |
フォームの項目名 | file、files など。null や空にはならない |
getOriginalFilename() |
送る側のファイル名 | 送る側が決める値。パスや .. を含むことがあるので、保存名には使わない |
getContentType() |
送る側が申告した種類 | 申告値。未定義なら null。判定の決め手にはしない |
isEmpty() |
空かどうか | 未選択、または中身が空のとき true |
getSize() |
バイト数 | 空なら 0 |
getBytes() |
中身のバイト配列 | 全体をメモリに載せる。小さなファイル向け |
getInputStream() |
中身のストリーム | 呼び出した側で閉じる。大きなファイルはこちら |
getResource() |
Resource |
受け取ったファイルを別の API へそのまま送るときに使える |
transferTo(File) |
なし | 移動またはコピー。同じ名前があれば先に削除される。呼ぶのは1回だけ |
transferTo(Path) |
なし | 既定の実装はストリームをコピーする |
transferTo(File) に相対パスを渡すと、基準になるディレクトリがコンテナ依存になります。保存先は絶対パスで組み立てるのが確実です。
種類の判定はどれで行うか
| 判定の材料 | だれが決める値か | 使い方 |
|---|---|---|
| 拡張子 | 送る側(ファイル名の一部) | 画面の accept や最初のふるい分けに使う |
Content-Type |
送る側(申告値) | 参考情報として記録する |
| 先頭バイト | ファイルの中身 | 受け付けるかどうかの判定に使う |
| 保存時の拡張子 | サーバー側 | 判定した種類から付け直す |
名前と中身が食い違っている場合に、拡張子や Content-Type だけを見ていると気づけません。受け付けの判定は中身(先頭バイト)で行い、保存時の拡張子はその結果から決めると、3つの情報がそろいます。
先頭バイト判定と保存先の確認を Java 21 で実行する
Service で使っている判定と、Path.normalize()+startsWith() の確認を、Spring なしの1ファイルで実行できる形にしました。
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/** 先頭バイトでの種類判定と、保存先の外に出ないことの確認を JDK だけで確かめる。 */
public class UploadChecks {
/** 先頭バイト(シグネチャ)→ 種類。 */
static final Map<String, byte[]> SIGNATURES = new LinkedHashMap<>();
static {
SIGNATURES.put("image/png", new byte[] {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
SIGNATURES.put("image/jpeg", new byte[] {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
SIGNATURES.put("image/gif", new byte[] {'G', 'I', 'F', '8'});
SIGNATURES.put("application/pdf", new byte[] {'%', 'P', 'D', 'F', '-'});
SIGNATURES.put("application/zip", new byte[] {'P', 'K', 0x03, 0x04});
}
/** ストリームの先頭 12 バイトを読んで種類を返す。MultipartFile#getInputStream() をそのまま渡せる。 */
static Optional<String> detect(InputStream in) throws IOException {
byte[] head = in.readNBytes(12);
// WebP は RIFF????WEBP の形
if (head.length >= 12 && startsWith(head, "RIFF".getBytes(StandardCharsets.US_ASCII))
&& Arrays.equals(Arrays.copyOfRange(head, 8, 12), "WEBP".getBytes(StandardCharsets.US_ASCII))) {
return Optional.of("image/webp");
}
for (var e : SIGNATURES.entrySet()) {
if (startsWith(head, e.getValue())) return Optional.of(e.getKey());
}
return Optional.empty();
}
static boolean startsWith(byte[] data, byte[] prefix) {
return data.length >= prefix.length
&& Arrays.equals(Arrays.copyOf(data, prefix.length), prefix);
}
/** 保存先ディレクトリの中に収まるパスだけを返す。 */
static Path resolveInside(Path root, String name) {
Path base = root.toAbsolutePath().normalize();
Path target = base.resolve(name).normalize();
if (!target.startsWith(base)) {
throw new IllegalArgumentException("保存先の外を指しています: " + name);
}
return target;
}
public static void main(String[] args) throws IOException {
Map<String, byte[]> samples = new LinkedHashMap<>();
samples.put("photo.png", new byte[] {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13});
samples.put("photo.jpg", new byte[] {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, 0, 16, 'J', 'F', 'I', 'F', 0, 1});
samples.put("anim.gif", "GIF89a......".getBytes(StandardCharsets.US_ASCII));
samples.put("doc.pdf", "%PDF-1.7\n%....".getBytes(StandardCharsets.US_ASCII));
samples.put("pic.webp", new byte[] {'R', 'I', 'F', 'F', 1, 2, 3, 4, 'W', 'E', 'B', 'P'});
samples.put("book.xlsx", new byte[] {'P', 'K', 3, 4, 20, 0, 6, 0, 8, 0, 0, 0});
samples.put("renamed.png", "<script>alert(1)</script>".getBytes(StandardCharsets.US_ASCII));
samples.put("memo.txt", "こんにちは".getBytes(StandardCharsets.UTF_8));
System.out.println("== 先頭バイトでの判定 ==");
for (var e : samples.entrySet()) {
String type = detect(new ByteArrayInputStream(e.getValue())).orElse("(一覧に無い)");
System.out.printf("%-12s -> %s%n", e.getKey(), type);
}
System.out.println("== 保存先の確認 ==");
Path root = Path.of("/var/app/uploads");
String[] names = {UUID.nameUUIDFromBytes("demo".getBytes()) + ".png", "a/b.png", "../../etc/passwd", "/etc/passwd", "x/../../y.png"};
for (String name : names) {
try {
System.out.printf("%-42s -> %s%n", name, resolveInside(root, name));
} catch (IllegalArgumentException ex) {
System.out.printf("%-42s -> 拒否(%s)%n", name, ex.getMessage());
}
}
}
}
java -Dstdout.encoding=UTF-8 UploadChecks.java の実行結果です。
== 先頭バイトでの判定 ==
photo.png -> image/png
photo.jpg -> image/jpeg
anim.gif -> image/gif
doc.pdf -> application/pdf
pic.webp -> image/webp
book.xlsx -> application/zip
renamed.png -> (一覧に無い)
memo.txt -> (一覧に無い)
== 保存先の確認 ==
fe01ce2a-7fba-38fa-baed-7c982a04e229.png -> /var/app/uploads/fe01ce2a-7fba-38fa-baed-7c982a04e229.png
a/b.png -> /var/app/uploads/a/b.png
../../etc/passwd -> 拒否(保存先の外を指しています: ../../etc/passwd)
/etc/passwd -> 拒否(保存先の外を指しています: /etc/passwd)
x/../../y.png -> 拒否(保存先の外を指しています: x/../../y.png)
結果から分かることを3つ挙げます。
renamed.pngは名前が.pngでも中身が PNG ではないため、一覧に無い種類として扱われます。 拡張子だけを見る判定では通ってしまう例です。book.xlsxはapplication/zipと判定されます。 xlsx や docx は ZIP 形式の入れ物なので、先頭バイトだけでは ZIP と区別できません。これらを受け付けるときは、中のファイル構成まで見る確認を足します。../../etc/passwdや/etc/passwd、途中に..を含む名前は、normalize()したあとのstartsWith()で保存先の外と分かります。 保存名を UUID にしていればこの確認に掛かることはありませんが、名前の作り方を後から変えたときの備えとして残しておくと安心です。
テキストファイル(memo.txt)のように決まった先頭バイトを持たない形式は、この方法では判定できません。受け付ける種類を絞る設計と相性のよい方法です。
保存先の選び方
| 保存先 | 向く場面 | 気を付ける点 |
|---|---|---|
| ローカルディスク | サーバー1台、手元での開発、小規模な社内ツール | 複数台にすると共有の仕組みが要る。バックアップを自分で用意する |
| オブジェクトストレージ | サーバーを複数台にする、容量が増えていく、配信もしたい | 接続設定と権限の管理が加わる。保存用の Service を差し替えられる形にしておく |
| データベース | 小さなファイルを、関連データと同じトランザクションで扱いたい | データベースの容量とバックアップ時間に影響する |
どの保存先でも、「保存名はサーバー側で作る」「元の名前・種類・大きさは別に記録する」「公開ディレクトリの外に置き、取り出すときはアプリを通す」の3点は共通です。この記事の FileStorageService のようにコントローラーと保存処理を分けておくと、ローカルディスクからオブジェクトストレージへの切り替えが Service の差し替えで済みます。
アップロード機能全体の対策の考え方(許可リスト、再エンコード、配信時のヘッダーなど)は、関連記事「ファイルアップロードのセキュリティ対策|実装コードと検証チェックリスト」で扱っています。この記事は Spring Boot と MultipartFile での書き方に絞っています。
対象バージョンとサポート期間
Spring Boot の世代ごとの OSS サポート期間です(spring.io の公開 API、確認日 2026年10月7日)。
| 世代 | 初回リリース | OSS サポート終了 |
|---|---|---|
| 3.5.x | 2025-05-31 | 2026-06-30 |
| 4.0.x | 2025-11-30 | 2026-12-31 |
| 4.1.x | 2026-06-30 | 2027-07-31 |
Spring Boot 4.0 は Java 17 以降と Servlet 6.1 を前提にしています。Spring Boot 3 系から移る場合、この記事のコードで変わるのは、スターターの名前(spring-boot-starter-web から spring-boot-starter-webmvc)と、@WebMvcTest のパッケージ(org.springframework.boot.webmvc.test.autoconfigure)です。
よくある質問
Q. @RequestParam と @RequestPart はどちらを使えばよいですか?
A. ファイルだけ、またはファイルと単純な文字列の項目なら @RequestParam で足ります。JSON のパートをオブジェクトに変換して受け取りたいときは @RequestPart を使います。
Q. max-file-size を上げたのに 413 になります。
A. Spring に届く前の層で断られている場合があります。nginx の client_max_body_size は既定が 1m なので、Spring Boot 側の max-request-size 以上にそろえます。
Q. 上限をなくすことはできますか?
A. spring.servlet.multipart.max-file-size を -1 にすると上限なしになります。ディスクやメモリを守る意味で、用途に合った値を決めておくほうが運用しやすくなります。
Q. MaxUploadSizeExceededException のハンドラーをテストするには?
A. MockMvc は実際のマルチパート解析を行わないため、テストでは例外を投げさせて応答の形を確かめます。上限の値そのものは、アプリを起動して大きなファイルを送って確かめます。
まとめ
- 受け取り方は3つ:
@RequestParamのMultipartFile、List<MultipartFile>、@RequestPartで JSON と同時 - 既定の上限は1ファイル 1MB・1リクエスト 10MB。
spring.servlet.multipart.max-file-sizeとmax-request-sizeで変える getOriginalFilename()とgetContentType()は送る側が決める値。保存名は UUID で作り、種類は先頭バイトで判定するPath.normalize()とstartsWith()で、保存先の中に収まることを確かめるMaxUploadSizeExceededExceptionは@RestControllerAdviceで受け、ProblemDetailの 413 で返す- MockMvc は実際のマルチパート解析を行わない。テストは
MockMultipartFileと例外の差し込みで書く - 前段の上限(nginx の
client_max_body_sizeなど)は、Spring Boot 側以上にそろえる
参考・出典
確認日はいずれも 2026年10月7日です。
- Spring Boot 4.1.1 How-to「Spring MVC(Handling Multipart File Uploads)」 https://docs.spring.io/spring-boot/how-to/spring-mvc.html
- Spring Boot 4.1.1 API「MultipartProperties」 https://docs.spring.io/spring-boot/4.1.1/api/java/org/springframework/boot/servlet/autoconfigure/MultipartProperties.html
- Spring Framework 7.0.9 リファレンス「Multipart Resolver」 https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/multipart.html
- Spring Framework 7.0.9 API「MultipartFile」 https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/multipart/MultipartFile.html
- Spring Framework 7.0.9 API「MaxUploadSizeExceededException」 https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/multipart/MaxUploadSizeExceededException.html
- Spring Framework 7.0.9 リファレンス「Error Responses」 https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html
- Spring Framework 7.0.9 リファレンス「MockMvc: Performing Requests」 https://docs.spring.io/spring-framework/reference/testing/mockmvc/hamcrest/requests.html
- Spring Boot 4.1.1 リファレンス「Testing Spring Boot Applications」 https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html
- Spring Boot 4.0 Migration Guide https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide
- Spring Boot 世代別サポート期間 https://api.spring.io/projects/spring-boot/generations
- Apache Tomcat 11.0 設定リファレンス「The HTTP Connector」 https://tomcat.apache.org/tomcat-11.0-doc/config/http.html
- nginx「ngx_http_core_module(client_max_body_size)」 https://nginx.org/en/docs/http/ngx_http_core_module.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Spring Bootファイルアップロード|MultipartFile実装コード全文」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。