綴Photo の勉強ノートに戻る
コードレビュー学習2026.05.11

画像アップロード上限を考え直した話 — 1ファイル / 合計 / ピクセル数 / decompression bomb

セキュリティDjango画像処理Pillow

コードレビューで指摘された「画像アップロード上限が緩い」を直した話と、上限値を決める時の考え方をまとめたものです(修正済み)。

この記事でわかること

  • 画像アップロード API で気にする 4 つの上限軸
  • 「1ファイル 20MB × 50件」が実は 1リクエスト ~1GB な落とし穴
  • decompression bomb(PNG/JPEG 解凍時の爆発)と Pillow の MAX_IMAGE_PIXELS
  • 上限値を決める時の「現実的な使い方 / リバプロ常識 / 攻撃面」の3視点

起きていた状態

Django REST framework で画像アップロードを受ける serializer。上限はこれだけ:

python
class PhotoUploadSerializer(serializers.Serializer):
    files = serializers.ListField(
        child=serializers.FileField(),
        allow_empty=False,
        max_length=50,  # ← ファイル数上限
    )
 
    def validate_files(self, files):
        max_size = 20 * 1024 * 1024  # 20MB / ファイル
        for file_obj in files:
            if file_obj.size > max_size:
                raise serializers.ValidationError(...)
            # 形式検証(HEIC / JPEG / PNG / WEBP ...)
            ...

つまり「1ファイル 20MB / 最大 50 件」= 理論最大 1GB のリクエスト。 認証必須なので即危険ではないが、誤投入や悪用時にサーバが死ぬ余地が残っていた。

気にする上限軸(4つ)

画像アップロードで効くリソース上限を整理すると 4 軸あります。見る場所が違います。

数字は settings/base.py。件数上限だけ serializer の ListField。検証中は DB に書かない。settings/base.py1ファイル / 合計 / 画素設定ファイル。DB には無い件数は ListField の max_lengthリクエストメモリ届いた files1ファイル / 合計 / 件数bytes と件。保存しないPillow のプロセスメモリ解凍後の画素ピクセル数(width × height)CPU/メモリ。保存しないPostgreSQL photos_photo通ったあとの行ファイル数が守る先検証中はまだ書かない

上限値の決め方

「いくつにすべきか」は文脈依存だが、判断軸は 3 つに整理できます:

  1. 現実的な使い方の最大値: ユーザが普通に投げる量はどれくらいか
  2. インフラの常識的な制約: reverse proxy / Cloudflare / Render 等の body サイズ制限
  3. 攻撃面: 認証/未認証、レート制限の有無

このプロジェクトの場合:

項目 決めた値 根拠
1ファイル 20MB 維持 iPhone HEIC/JPEG の現実的最大に余裕。10MB だと過剰圧縮されるシーンが出る
1リクエスト合計 100MB reverse proxy の常識的 body 上限 / 写真30枚相当
ファイル数 30 イベント1回分の写真 ~20-30 枚が実用上限
ピクセル数/画像 40M ピクセル 8K(7680×4320=33M)を超える解像度をリジェクト

decompression bomb 攻撃

ファイルサイズが小さくても、解凍すると爆発する画像を投げる攻撃があります。

圧縮バイトはリクエストメモリ。広げた画素はプロセスメモリ。どちらも保存しない。DB には無い。リクエスト100KB の PNGリクエストメモリ保存しない。小さく送れるImage.open解凍した直後100,000 × 100,000 px10G px × 4byte = 40GBプロセスメモリ。保存しないファイルサイズの上限だけでは、この 40GB を止められない。攻撃者は小さいリクエストで送れるので、レート制限を回避しやすい。

Pillow にはこの保護がデフォルトで入っていて、Image.MAX_IMAGE_PIXELS を超えた画像を Image.DecompressionBombError で弾きます。ただしデフォルト値(約 89M ピクセル)はやや甘いです。プロジェクト固有の値で明示するのが安全です:

python
# serializers.py の冒頭で1回設定すれば module load 時に効く
from PIL import Image
from django.conf import settings
 
Image.MAX_IMAGE_PIXELS = getattr(settings, "PHOTO_UPLOAD_MAX_PIXELS", 40_000_000)

二重防御

「個別 validate でピクセル数チェック」+「Pillow デフォルト保護」の二重で守ります:

python
def validate_files(self, files):
    max_pixels = getattr(settings, "PHOTO_UPLOAD_MAX_PIXELS", 40_000_000)
    ...
    try:
        with Image.open(file_obj) as img:
            img.verify()
        file_obj.seek(0)
        with Image.open(file_obj) as img2:
            if img2.width * img2.height > max_pixels:
                raise serializers.ValidationError(
                    f"{file_obj.name}: 画像のピクセル数が大きすぎます "
                    f"({img2.width}×{img2.height}px / 上限 {max_pixels:,} ピクセル)。"
                )
    except (UnidentifiedImageError, OSError, Image.DecompressionBombError):
        self.fail("invalid_type")
40M は settings/base.py。コピー先も画素もプロセスメモリ。DB には無い。settings/base.py PHOTO_UPLOAD_MAX_PIXELS = 40M設定ファイル。画像は無い。DB には書かないImage.MAX_IMAGE_PIXELSプロセスメモリへコピー保険。Pillow 内部で先に発火解凍後の画像width × height を数えるプロセスメモリ。ユーザー向けメッセージ左は漏れた経路の保険。右は理由が読めるエラーのためである。

二重に守ることで「片方をうっかり外しても」セーフです。

合計サイズの早期チェック

個別ファイル検証より先に合計サイズを見ると、巨大リクエストを早期に弾けます:

python
def validate_files(self, files):
    max_total = getattr(settings, "PHOTO_UPLOAD_MAX_TOTAL_SIZE", 100 * 1024 * 1024)
    
    total_size = sum(getattr(f, "size", 0) or 0 for f in files)
    if total_size > max_total:
        raise serializers.ValidationError(
            f"アップロード合計サイズが {max_total // (1024*1024)}MB を超えています "
            f"(現在: {total_size // (1024*1024)}MB)。"
        )
    
    for file_obj in files:
        # 個別検証
        ...

合計サイズチェックを先に置くメリット:

  • 巨大リクエストを 個別検証ループに入る前 に弾けます = CPU/メモリの無駄遣いを避けます
  • エラーメッセージが「合計サイズが原因」と明確になります
  • 個別ファイルが全部 OK なのに合計で死ぬ「気づきにくい失敗」を出しません

HEIC/HEIF は別枠

Pillow は標準では HEIC を開けないので、ピクセル数検証はスキップ:

python
if extension in {".heic", ".heif"}:
    if not _is_heif_bytes(head):
        self.fail("invalid_type")
    # HEIC/HEIF は Pillow で開けないためピクセル数検証はスキップ。
    # 1ファイル容量上限 (max_size) で間接的に守る。
    continue  # ← ここで打ち切り。下の Pillow 処理(ピクセル数検証)に進まない

HEIC のピクセル爆弾は 1ファイル容量上限で間接的に守る形になります。デコードを backend で走らせるなら pillow-heif 等の plugin 入れて検証する筋もあるが、攻撃面とのコストバランスで「現状は容量で守る」に倒した。

学びポイント

  • 「1ファイル ✕ 件数 = 理論最大リクエスト」を計算すると、合計サイズ未設定の怖さが見えます
  • 合計サイズチェックは個別検証より前に置くと早期 reject できます

おわりに

「認証してるからユーザは攻撃しない」は弱い前提で、誤投入 / 共有アカウント / アプリの自動アップロードバグなどで意図せず大量送信が起きる経路はあります。

上限を 「ユーザが普通に使う最大値」 + 「インフラの常識的上限」 + 「攻撃面の最小化」 の 3 視点で揃えると、運用に耐えるラインが見えてきます。


この記事は2026年5月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。