Spring Bootでファイルアップロードを実装するときは、受け取ったファイルを保存する処理に加えて、容量や形式の検証、保存先の管理、利用者の権限確認が必要です。受信できることと、保存・配信してよいことは分けて判断します。
この記事では、Controllerの基本を理解している方向けに、MultipartFileによる受信から、検証、非公開領域への保存、異常系テストまでを順に整理します。通常のフォーム検証や例外処理の基本は関連記事で補い、ここではファイル固有の判断を扱います。
Spring Bootで実装するファイルアップロードの全体像
対象範囲と受信・検証・保存の流れ
基本の流れは、権限確認、受信、検証、保存、利用可能な状態への切り替えです。ただし、multipartリクエストの解析では、Controllerが実行される前にコンテナ側の一時領域へデータが書き込まれる場合があります。Controller内の検証だけで、受信時のディスク使用量まで抑えられるわけではありません。
- 認証とアップロード先への権限を確認する。
- 受信層の容量制限を適用し、ファイル数や必須項目を確認する。
- 非公開の作業領域で、容量・形式・内容を検証する。
- サーバー側で生成した保存名を使い、既存ファイルを上書きせずに保存する。
- 所有者や検査状態を記録し、すべて成功してから利用を許可する。
Controllerは入力の受け渡し、検証サービスは受付条件の判定、保存サービスはファイル操作を担当すると、失敗時の扱いを整理しやすくなります。DBにはファイルID、所有者、保存キー、サイズ、検出した形式、処理状態などを記録し、ファイル本体と対応付けます。
許可するファイルと利用者の権限を先に決める
最初に「何のために、誰が、どの形式を、何個まで送れるか」を決めます。プロフィール画像と社内文書では必要な検査が違うため、任意のファイルを受け付ける設計から始めると、後から制限しにくくなります。
受付条件には、許可形式、1ファイルの最大バイト数、1リクエストのファイル数、利用者ごとの保存総量を含めます。アップロード先が案件や組織にひも付く場合は、その対象への追加権限も必要です。所有者IDをフォームから受け取ってそのまま信用せず、認証済みの利用者と対象データから決定します。
MultipartFileでファイルを受け取る
multipart/form-dataの送信とControllerの入口
HTMLフォームからファイルを送る場合は、POSTとmultipart/form-dataを使います。ファイル入力の名前をfileにした場合、Controller側でも同じ名前で受け取ります。JavaScriptのFormDataを使う場合、boundaryを含むContent-Typeの設定は通常ブラウザーに任せます。
次は受信メソッドの例です。uploadServiceはアプリ側で実装するサービスで、権限確認、検証、保存、状態の確定までを担当します。
@PostMapping(
path = "/uploads",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<String> upload(
@RequestParam("file") MultipartFile file,
Principal principal) {
String fileId = uploadService.receive(file, principal);
return ResponseEntity.status(HttpStatus.CREATED).body(fileId);
}MultipartFileはorg.springframework.web.multipart、Principalはjava.securityの型です。認証必須の設定は別途必要で、引数にPrincipalを置くだけでは認証を強制できません。サービスが返すIDも保存パスではなく、ダウンロード時の権限確認に使う管理用IDとします。
また、単一の引数を宣言しただけで、余分なファイルパートや同名パートがすべて拒否されるとは考えないでください。単一ファイルだけを許可する仕様なら、受信パートの数と名前も検証対象にします。非同期で検査する場合は、受信済みの状態と利用可能な状態を分け、完了済みとして返さない設計が必要です。
容量上限と保存先を設定として管理する
Servletベースの構成では、multipartの容量設定としてspring.servlet.multipart.max-file-sizeとspring.servlet.multipart.max-request-sizeを使う構成が一般的です。次は1ファイルの上限を10MB、リクエスト全体の上限を12MBにする設定例です。上限は用途に合わせて決めてください。
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 12MB
location: ${UPLOAD_TEMP_DIR}
app:
upload:
storage-dir: ${UPLOAD_STORAGE_DIR}リクエスト全体には、ファイル以外にフォーム項目やmultipartの区切り情報も含まれます。そのため、全体の上限はファイル上限と別に設計します。アプリ側の検証でも上限を使うなら、単位の違いや設定の食い違いが起きないよう、比較するバイト数を明確にしてください。
locationはmultipart処理用の一時領域、app.upload.storage-dirはこの例で定義した独自の保存先設定です。後者を書くだけで保存処理が切り替わるわけではなく、アプリ側で読み込む必要があります。各ディレクトリは事前に準備し、権限と空き容量を確認します。
環境ごとの値の切り替えは、関連記事「profilesと環境変数による設定の分離」で補足しています。採用するSpring Bootとコンテナで設定が有効になっているかも確認してください。

保存前にファイルを検証する
未指定・空ファイル・容量超過を確認する
ファイルのパート自体がない状態と、パートはあるものの中身が0バイトの状態を区別します。必須の@RequestParamが欠けた場合はControllerに入る前にエラーになることがあります。一方、受け取ったMultipartFileの空判定にはisEmpty()、サイズの確認にはgetSize()を使えます。
空ファイルを禁止するかは用途によりますが、画像や添付文書なら拒否する仕様が考えられます。容量は受信層とアプリ層の両方で制限し、上限ちょうどを許可するかも決めておきます。
アプリの作業領域へコピーするときも、ストリームから読み取った累計バイト数を数え、上限を超えた時点で中止する設計にします。無制限にgetBytes()で全体をメモリーへ展開すると、同時アップロード時のメモリー消費が増えます。ただし、コピー時の制限だけでは、その前段で発生した一時ファイルへの書き込みは防げません。
拡張子・申告されたContent-Type・内容を照合する
元のファイル名とgetContentType()の値は、送信者が操作できる情報です。拡張子が.png、Content-Typeがimage/pngでも、中身がPNGだと確定したことにはなりません。ブラウザーのaccept属性も選択時の補助であり、サーバー側の検証を代替しません。
まず、許可する拡張子を限定し、申告されたContent-Typeが受付条件と整合するかを確認します。そのうえで、ファイルのシグネチャや形式に対応する解析処理を使い、内容を確かめます。申告値が未指定の場合や汎用的な値の場合を許可するかも、クライアントの仕様に合わせて決めてください。
画像なら、対応するデコーダーで読めるかだけでなく、幅・高さ・総画素数などの制限も必要です。圧縮ファイルを許可するなら、展開後の総量、ファイル数、階層の深さ、展開先パスを別に制限します。アップロード時のバイト数が小さくても、解析や展開で大量の資源を使う場合があります。
形式の判定は、ファイルが無害であることの保証ではありません。先頭のシグネチャが一致しても、破損データや不正な内容を含む可能性があります。許可形式ごとに検査方法と処理時間・メモリーの上限を決め、解析ライブラリも適切に保守します。
付随するフォーム項目とファイル本体の検証を分ける
タイトルや説明文などの通常項目はDTOで検証し、ファイル本体の容量・形式・内容は専用の処理へ分けます。@Validを付けただけで、ファイル内容が解析されるわけではありません。
通常項目の不備があるときにファイルを確定しないよう、処理順もそろえます。保存先の案件IDなどが入力される場合は、値の形式チェックに加えて、その案件への操作権限をサービス側で確認します。
DTOの制約やエラーの受け取り方は、「@ValidとBindingResultによる入力チェック」を参照してください。

ファイル名と保存先を安全に扱う
保存名をサーバー側で生成し、上書きを防ぐ
getOriginalFilename()の値を保存パスへ直接つなげず、保存名はサーバー側で生成します。たとえばランダムなUUIDを保存キーとして使い、元の名前は表示用のメタデータとして分離します。保存名に拡張子が必要なら、利用者の申告値ではなく、検証済みの形式から決めます。
ランダムな名前でも、衝突を想定した保存操作は必要です。書き込み先を新規作成する場合は、CREATE_NEWなど、既存ファイルがあれば失敗する方法を使います。「存在確認してから通常の書き込みをする」だけでは、確認と書き込みの間に状態が変わる可能性があります。
作業ファイルを確定領域へ移動する設計でも、上書きしないことを実際のファイルシステムとAPIの動作で確認します。原子的な移動と上書き禁止は別の条件です。ATOMIC_MOVEを指定すれば、すべての環境で両方を満たせるとは考えないでください。
パストラバーサルとシンボリックリンクを考慮する
パストラバーサルは、../などを含む入力によって、想定した保存ディレクトリの外を操作させる攻撃です。絶対パス、バックスラッシュ、OS固有のパス表現も考慮が必要です。元のファイル名を保存パスに使わない設計にすると、こうした入力をパス解決から切り離せます。
保存先は信頼できる設定から取得し、生成した保存キーだけを結合します。補助的に、正規化したパスが想定した基点の配下かをPathとして確認できますが、文字列の前方一致だけで判定しないようにします。
ただし、normalize()はシンボリックリンクの参照先を検証しません。実体パスを確認しても、その直後に親ディレクトリを差し替えられる環境では競合が残ります。保存先とその親ディレクトリを、信頼できない利用者やプロセスが変更できないことが前提です。Windowsではジャンクションなどの再解析ポイントも確認対象になります。
公開領域から分離し、保存先の権限を制限する
保存先は、Webサーバーの公開ディレクトリやアプリの静的リソース配信先から分離します。ランダムなファイル名でも、URLを知っていれば誰でも取得できる配置では非公開になりません。
ファイルの読み書きに必要な権限をアプリの実行アカウントへ限定し、一般利用者による変更やファイルの実行を許可しない構成にします。具体的な権限設定はOSや運用方式で変わるため、実行アカウントを使って読み取り・作成・削除が期待どおりか確認します。
コンテナや複数台構成では、ローカル保存したファイルが再作成後も残るか、別のインスタンスから取得できるかも確認が必要です。必要な永続性や共有が満たせない場合は、永続ボリュームやオブジェクトストレージを含めて保存方式を見直します。
一時保存から確定までの失敗と後始末を設計する
コンテナの一時ファイルとは別に、アプリが管理する非公開の作業ファイルを用意すると、検査中と利用可能な状態を分けられます。リクエスト終了後に非同期処理する場合は、MultipartFileをそのまま保持せず、リクエスト中にアプリ管理下へ保存しておきます。
処理状態は、たとえば「受信中」「検査待ち」「利用可能」「失敗」に分けます。検証に通ったファイル本体と、所有者などの管理情報がそろって初めて「利用可能」にし、取得処理はその状態だけを配信対象にします。
DBのトランザクションがロールバックされても、ファイルへの書き込みまで自動で戻るわけではありません。コピー失敗、検証失敗、確定処理失敗、DB更新失敗ごとに、削除するものと調査のために保持するものを決めます。削除に失敗した場合は元のエラーを隠さず、回収対象として記録します。
プロセスの強制終了では、終了処理が実行されないこともあります。一定時間を過ぎた未完了データを確認する回収処理を用意し、処理中のファイルや利用可能なファイルを誤って削除しないよう、状態と最終更新時刻を照合してください。
アップロードの悪用と意図しない公開を防ぐ
認証・認可と認証方式に応じたCSRF対策
認証では利用者を特定し、認可ではその利用者が対象へファイルを追加してよいかを判断します。アップロード時だけでなく、ダウンロード、一覧表示、削除でも対象ごとの権限確認が必要です。ファイルIDが推測しにくいことは、認可の代わりにはなりません。
セッションCookieなど、ブラウザーが認証情報を自動送信する方式では、アップロードにもCSRF対策を適用します。multipartだからという理由で保護を無効化せず、CSRFトークンの送信方法とフィルターの処理順を確認します。
JavaScriptがAuthorizationヘッダーへトークンを明示的に設定する構成では、Cookie認証とは条件が異なります。ただし、別経路でCookie認証も受け付けていないかを含めて判断してください。また、認証やCSRFの検証前に一時ファイルが作られる構成なら、その段階にも容量・同時実行数の制限が必要です。
リクエスト数・保存総量・一時領域の上限を考える
1ファイルの容量を制限しても、小さなファイルを大量に送られると保存先は埋まります。利用者単位の送信頻度、同時処理数、ファイル数、保存総量を別々に制限します。アプリ全体の上限や、一時領域の空き容量も監視対象です。
保存総量の判定では、複数リクエストが同時に「まだ空きがある」と判断する競合に注意します。必要なら容量を予約し、失敗時に予約を解放する仕組みを設けます。処理時間の上限や低速な送信へのタイムアウトも、プロキシ・コンテナ側を含めて決めてください。
用途に応じた隔離・マルウェア検査と配信時の制御
外部から受け取った文書を他の利用者へ配布する場合などは、検査待ちの隔離領域とマルウェア検査を検討します。検査サービスの停止、タイムアウト、判定不能を「問題なし」と扱わず、利用を保留する状態を用意します。検査を通過しても、未知の脅威まで排除できるとは限りません。
配信時は権限と利用可能な状態を確認し、サーバーが決めたContent-Typeを設定します。ブラウザー表示が不要なら、Content-Disposition: attachmentを使う設計が考えられます。元の名前をヘッダーへ生で連結せず、改行や制御文字を排除し、適切なヘッダー生成処理を使います。
X-Content-Type-Options: nosniffや非公開データに適したキャッシュ制御も組み合わせます。HTMLやSVGなどの能動的な内容をブラウザーへ表示する用途では、別オリジンへの分離など追加の検討が必要です。アップロードできる形式と、アプリと同じオリジンで表示してよい形式は、別に判断します。
エラーレスポンスと異常系テストを整える
容量超過・形式不正・保存失敗を区別して返す
利用者が入力を直せるエラーと、サーバー側の障害を分けます。すべてを「アップロード失敗」にすると、ファイルを選び直すべきか、時間を置くべきかが伝わりません。次はアプリで定義する応答方針の例で、フレームワークの既定動作ではありません。
- 未指定・禁止された空ファイル:400と、入力の修正を促すメッセージ。
- 容量上限の超過:413と、許可する上限を示すメッセージ。
- 許可しない形式や内容の不整合:400または422など、APIで統一した応答とエラーコード。
- 認証済み利用者の操作権限不足:403を基本とする応答。
- ディスク障害などの保存失敗:500などのサーバー側エラーと、内部情報を含まないメッセージ。
HTTPリクエスト自体のメディアタイプが未対応の場合と、multipart内のファイルが業務上の許可形式に合わない場合も区別します。容量超過の例外型や処理される場所は、Spring Boot、コンテナ、multipartの構成で確認が必要です。プロキシが先に拒否した応答は、ControllerAdviceだけでは統一できません。
Adviceの基本構文は、「Spring Bootの例外処理を共通化する方法」で確認できます。アップロードでは、どの層の失敗をアプリで扱えるかまで整理して適用してください。

内部パスや例外の詳細をレスポンスに含めない
保存失敗の例外には、保存先の絶対パスやOSの情報が含まれることがあります。e.getMessage()をそのまま返さず、利用者向けには固定のエラーコードとメッセージを返します。
調査には、リクエストID、処理段階、管理用のファイルID、失敗の種類を記録します。認証情報やファイル本体をログへ出さず、元のファイル名も個人情報や制御文字を含む入力として扱います。内部情報が必要なログは、閲覧権限と保存期間を制限してください。
不正な形式・ファイル名・権限・保存失敗をテストする
正常なファイルが保存されることに加えて、拒否した入力でファイルが利用可能にならないことを確認します。以下の項目をテストします。
- 必須・容量:パートなし、0バイト、上限の直前・一致・超過、余分なファイル、同名パートの重複。
- 形式:拡張子と内容の不一致、Content-Typeの偽装・未指定、途中で切れたファイル、解析時に制限を超えるデータ。
- ファイル名:親ディレクトリ参照、絶対パス、区切り文字、長すぎる名前、制御文字を含む入力。
- 権限:未認証、権限のない保存先、他人のファイルID、Cookie認証でのCSRFトークン不足。
- 保存障害:途中の書き込み失敗、容量不足、名前の衝突、確定失敗、DB更新失敗、後始末の削除失敗。
- 競合・復旧:同時アップロードによる総量超過、処理途中の停止、未完了ファイルの回収、リンク経由の保存先逸脱。
レスポンスだけでなく、既存ファイルが変化していないこと、保存先の外へ書き込まれていないこと、失敗したデータが取得できないことも確認します。孤立ファイルや容量予約が残った場合に、回収できるかまでテスト対象にします。
保存処理をインターフェースで分離すると、書き込みや削除の失敗をテスト用の実装で再現しやすくなります。一方、OSの権限、シンボリックリンク、移動の挙動は、採用するファイルシステムでの確認も必要です。
実サーバーとプロキシを含む容量制限を確認する
MockMvcはControllerや検証処理の確認に使えますが、multipartのテストで実コンテナと同じ受信・解析経路を通るとは限りません。MockMvcで容量超過を再現できたとしても、それだけで実サーバーの容量制限を確認したことにはなりません。
実際に起動したアプリへHTTPで送信し、単一ファイルの上限と、フォーム項目を含むリクエスト全体の上限を別々に確認します。リバースプロキシがある場合は、アプリへの直接接続とプロキシ経由を分け、どの層で拒否されたかを調べます。
応答のステータスや本文に加えて、一時領域の使用量、受信中断後のファイル、タイムアウト時の状態も確認します。検証記録にはSpring Boot、Java、コンテナ、プロキシのバージョンと有効な設定を残すと、環境変更時の比較に使えます。
まとめ
Spring Bootのファイルアップロードでは、MultipartFileによる受信を入口として、許可する容量・形式・利用者を明確にします。申告された名前やContent-Typeだけを信用せず、用途に合わせて内容を検証してください。
保存名はサーバー側で生成し、非公開の領域へ上書きせずに保存します。保存先と親ディレクトリの権限を制限し、検査・保存・管理情報の記録が完了するまで取得を許可しない設計にすると、途中の失敗も扱いやすくなります。
実装後は、形式不正や権限不足だけでなく、保存失敗と後始末、同時実行、実サーバーとプロキシでの容量超過まで確認します。受付条件と利用環境を確定し、その条件に対応する検証処理とテストをそろえることが、安全な保存へ進むための土台になります。


コメント