「ファイルアップロードAPIを壊さない設計|multipart・filename・hash・安全な保存」の内容を表す技術イラスト
UNIT 00LAB NOTES FIG.00 · EYECATCH

ファイルアップロードAPIを壊さない設計|multipart・filename・hash・安全な保存

multipart/form-dataによるファイルアップロードAPIを安全に設計するため、part構造、filename、形式検証、size・hash、冪等性、Python保存処理を実例で解説します。

DOC NO.EKP-00-1020
DATE
REVB
READ約 13 分

ファイルアップロード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 namefileAPI契約上の入力名
original filenamebracket.step利用者に見せる元の名前
declared media typeapplication/step送信側が宣言した形式
file contentbyte列保存・検査する実体
text fielddrawing_id=dwg-000184fileに関連付ける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、画像など形式が増えても、保存・検証・再実行・追跡の原則を再利用できます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次