ファイルアップロードAPIでは、JSONだけを送るAPIと異なり、field名、ファイル名、media type、実体のbyte列が一つのrequestに共存します。受信できたことだけを成功条件にすると、同名ファイルの上書き、拡張子偽装、途中までの保存、再送による二重登録が起こり得ます。
この記事では、multipart/form-dataを単なる送信形式ではなく、ファイルとメタデータを安全に受け渡すためのデータ契約として整理します。
multipart/form-dataで解決すること
JSONは構造化データの交換に向いていますが、binary fileをそのままJSON stringへ入れることはできません。Base64で文字列化する方法もありますが、byte列が増え、巨大な本文を一括解析しやすくなります。
multipart/form-dataは、一つのHTTP bodyを複数のpartへ分けます。たとえば、図面ファイルと図面ID、revision、説明を同じrequestで送れます。RFC 7578では、bodyはboundaryで区切られた一連のpartとして定義されています。
POST /uploads HTTP/1.1
Content-Type: multipart/form-data; boundary=example-boundary
--example-boundary
Content-Disposition: form-data; name="drawing_id"
dwg-000184
--example-boundary
Content-Disposition: form-data; name="file"; filename="bracket.step"
Content-Type: application/step
...file bytes...
--example-boundary--
top-levelのContent-Typeはboundaryを示し、各partのContent-Dispositionはfield名を示します。file partではfilenameを付けられ、Content-Typeでmedia typeを宣言できます。boundaryはfile本文に現れない値でなければならず、通常はHTTP client libraryに生成を任せます。headerだけを手作業で設定し、body側のboundaryと食い違わせてはいけません。
partをデータ構造として読む
受信側では、multipart bodyを次の関係へ分解します。
| 要素 | 例 | 役割 |
|---|---|---|
| field name | file | API契約上の入力名 |
| original filename | bracket.step | 利用者に見せる元の名前 |
| declared media type | application/step | 送信側が宣言した形式 |
| file content | byte列 | 保存・検査する実体 |
| text field | drawing_id=dwg-000184 | fileに関連付けるmetadata |
RFC 7578では、各partにContent-Disposition: form-dataとname parameterが必要です。fileのfilenameは、名前が不明、無意味、非公開である場合には存在しないこともあります。したがって、filenameを必須IDとして扱う設計は避けます。
複数ファイルを同じfieldへ送る場合は、同じnameを持つfile partを繰り返します。古いmultipart/mixedの入れ子方式は非推奨です。受信側のframeworkが一件取得と複数件取得を別APIにしている場合は、想定した件数を明示的に検証します。
filenameを保存先に使わない
filenameは利用者が送る入力値です。次のような値が届く可能性があります。
../../config.json
C:\Users\example\drawing.step
report/../../same-name.pdf
RFC 7578も、filenameを盲目的に使用せず、含まれるdirectory pathを使わないよう求めています。元の名前は表示用metadataとして正規化し、保存先はserverが生成したfile_idまたはstorage keyで決めます。
{
"file_id": "file_7f8c1d2a",
"original_filename": "bracket.step",
"storage_key": "uploads/file_7f8c1d2a",
"declared_media_type": "application/step"
}
同名ファイルを上書きしないこと、名前を失ってもfileを識別できることが重要です。Unicode正規化、制御文字の除去、表示文字数の上限も契約に含めます。非ASCII filenameの扱いは実装差があるため、送受信するclientを使った往復テストが必要です。RFC 7578ではpercent-encodingを選択肢として示す一方、multipart/form-dataのfilename* parameterは使用しないとしています。
Content-Typeと実体を分けて検証する
file partのContent-Typeは送信側の宣言であり、実体の保証ではありません。image/pngと宣言された実行ファイルや、.csvという名前のZIP fileも送信できます。
検証は複数層に分けます。
- API契約:許可するfield、part数、file数を確認する
- HTTP metadata:宣言されたmedia typeとfilenameの長さを確認する
- byte列:magic numberやformat parserで内容を確認する
- 業務schema:CSV header、JSON Schema、CAD format versionなどを確認する
- 保存後:sizeとhashを記録し、必要ならmalware scanを行う
形式が不明なfileにはapplication/octet-streamを使えますが、「安全なfile」という意味ではありません。拡張子、宣言media type、検出形式が一致しない場合に拒否するか隔離するかを決めます。
保存recordを先に設計する
file本体だけを保存すると、後から出所、検証結果、関連recordを追跡できません。最低限、次のmetadataを持たせます。
{
"file_id": "file_7f8c1d2a",
"owner_id": "project_0184",
"original_filename": "bracket.step",
"declared_media_type": "application/step",
"detected_format": "step",
"size_bytes": 2481024,
"sha256": "<64 hexadecimal characters>",
"storage_key": "uploads/file_7f8c1d2a",
"status": "available",
"uploaded_at": "2026-10-13T04:30:00Z",
"schema_version": 1
}
file_idは内部参照、owner_idは図面やprojectとの関係、original_filenameは表示、storage_keyは物理保存先です。役割を分けることで、object storageへの移行やfile名変更でも参照を保てます。
hashはbyte列が同じかを検証する値です。同じhashでも、異なるprojectへ別目的で登録されたfileを同一recordにまとめるとは限りません。hashと業務IDは別の識別子です。
入力から公開までを状態で管理する
安定した受信処理は、fileを最初から公開領域へ置きません。
multipart requestを受信
↓
part数・field名・認証を確認
↓
一時fileへstreamしながらsizeとSHA-256を計算
↓
形式・業務schema・malwareを検査
↓
server生成IDの保存先へ確定
↓
metadata recordをavailableへ更新
検査前はstagingまたはquarantined、利用可能ならavailable、拒否時はrejectedなどの状態を持たせます。file保存とDB更新は通常、一つのtransactionにできません。DB登録失敗時の孤立file削除、file移動失敗時のrecord rollback、一定時間残ったstagingの回収を設計します。
Pythonでstream保存する
次の例は、file-like objectをchunk単位で読み、一時fileへ書きながら上限とSHA-256を検証します。保存先には元のfilenameを使いません。
from pathlib import Path, PurePath
import hashlib
import os
import tempfile
import uuid
def display_filename(raw_name: str | None) -> str:
if not raw_name:
return "unnamed"
name = PurePath(raw_name.replace("\\", "/")).name
return name[:255] or "unnamed"
def store_upload(
stream,
original_filename: str | None,
declared_media_type: str | None,
storage_dir: Path,
max_bytes: int,
) -> dict:
storage_dir.mkdir(parents=True, exist_ok=True)
descriptor, temp_name = tempfile.mkstemp(
dir=storage_dir,
prefix=".upload-",
)
digest = hashlib.sha256()
total = 0
try:
with os.fdopen(descriptor, "wb") as output:
while chunk := stream.read(1024 * 1024):
total += len(chunk)
if total > max_bytes:
raise ValueError("file size limit exceeded")
digest.update(chunk)
output.write(chunk)
output.flush()
os.fsync(output.fileno())
file_id = "file_" + uuid.uuid4().hex
final_path = storage_dir / file_id
os.replace(temp_name, final_path)
return {
"file_id": file_id,
"original_filename": display_filename(original_filename),
"declared_media_type": declared_media_type,
"size_bytes": total,
"sha256": digest.hexdigest(),
"storage_key": final_path.name,
}
except Exception:
Path(temp_name).unlink(missing_ok=True)
raise
Pythonのtempfile.mkstemp()は、競合を避けて一時fileを作成します。hashlibはchunkごとにupdate()できるため、file全体をmemoryへ載せずにdigestを計算できます。
一時fileと確定先を同じfilesystemに置けば、os.replace()による名前変更を原子的に行える環境があります。異なるfilesystem間では失敗し得るため、配置を固定し、実環境で確認します。object storageを使う場合は、そのserviceのupload完了、copy、条件付きwriteの仕様に合わせて状態管理します。
正常系と異常系を契約にする
| ケース | 判定 | 代表的な処理 |
|---|---|---|
| 許可形式・上限内 | 正常 | IDを発行しmetadataを返す |
| file fieldなし | 入力不正 | 必須field error |
| 未定義fieldあり | 契約違反 | 拒否または無視を仕様化 |
| size上限超過 | 入力不正 | 読取停止・一時file削除 |
| format不一致 | 検証失敗 | 拒否または隔離 |
| 同じrequestの再送 | 重複候補 | idempotency keyを照合 |
| 保存後にDB失敗 | 内部失敗 | file削除または回収対象へ記録 |
HTTP serverやproxyにもbody size上限がある場合があります。applicationの上限だけでなく、経路上の各層を整合させます。Content-Lengthだけを信用せず、stream中の実測byte数でも上限を守ります。
冪等性と重複を分ける
通信が切れると、clientは保存成功を知らず同じuploadを再送することがあります。file名だけで重複判定すると、同名の別内容と、名前変更された同一内容を区別できません。
更新契約には、次を組み合わせます。
- clientがrequestごとにidempotency keyを送る
- serverは利用者またはprojectとkeyの組を一意にする
- 初回のsize、hash、結果の
file_idを保存する - 同じkey・同じ内容なら初回結果を返す
- 同じkey・異なる内容なら競合として拒否する
hashは受信完了後に確定するため、同時requestの排他にはDBの一意制約も必要です。再実行で別fileを作る業務なのか、同じ結果へ収束させるのかをendpoint単位で明文化します。
セキュリティと機密情報を分離する
RFC 7578は、multipart/form-data自体に完全性や機密性の機能がないこと、送信fileが任意の実行可能contentを含み得ることを指摘しています。HTTPS、認証、認可、保存後のaccess controlは別途必要です。
- 元filenameから保存pathを作らない
- file数、part数、header長、file size、総body sizeを制限する
- ZIPなどは展開後size、file数、path traversalも検査する
- parserや変換toolを低権限・隔離環境で実行する
- 公開bucketと検査前bucketを分離する
- API keyやtokenをform fieldやfile内へ混在させない
- 通常logへfile本文や機密metadataを出さない
利用者がfileをdownloadするときも、元filenameをresponse headerへ戻す処理でheader injectionや文字コード問題を起こさないよう、利用するframeworkの安全なAPIを使います。
CAD・DB・自動化へ再利用する
CAD file、図面PDF、BOM、試験結果を同じupload基盤へ載せる場合、共通部分と形式固有部分を分けます。
共通部分はfile_id、size、hash、storage key、status、所有関係です。形式固有部分はCAD version、unit、sheet数、CSV schema versionなどです。変換結果には入力file_idと変換program versionを記録します。
この構造なら、API受信、object storage保存、DB metadata、変換job、Webhook通知を安定したIDで接続できます。将来保存先を変更しても、業務recordはstorage pathではなくfile_idを参照し続けられます。
まとめ
ファイルアップロードは、fileを受け取って同じ名前で保存する処理ではありません。multipartの構造、利用者由来のmetadata、実体byte列、保存recordを分離したデータ契約が必要です。
- boundaryとpart構造の生成は信頼できるclient libraryへ任せる
name、filename、Content-Type、byte列の役割を分ける- 元filenameは表示用とし、保存先にはserver生成IDを使う
- 宣言media typeだけでなくfile実体と業務schemaを検証する
- 一時fileへstreamしながらsizeとhashを計算する
- staging、available、rejectedなどの状態を管理する
- idempotency key、hash、一意制約で再送を制御する
- 検査前file、公開file、秘密情報を分離する
この設計を共通化すると、CAD、PDF、CSV、JSON、画像など形式が増えても、保存・検証・再実行・追跡の原則を再利用できます。
