コードレビューで指摘された「画像アップロード上限が緩い」を直した話と、上限値を決める時の考え方をまとめたものです(修正済み)。
この記事でわかること
- 画像アップロード API で気にする 4 つの上限軸
- 「1ファイル 20MB × 50件」が実は 1リクエスト ~1GB な落とし穴
- decompression bomb(PNG/JPEG 解凍時の爆発)と Pillow の
MAX_IMAGE_PIXELS - 上限値を決める時の「現実的な使い方 / リバプロ常識 / 攻撃面」の3視点
起きていた状態
Django REST framework で画像アップロードを受ける serializer。上限はこれだけ:
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 軸あります。見る場所が違います。
上限値の決め方
「いくつにすべきか」は文脈依存だが、判断軸は 3 つに整理できます:
- 現実的な使い方の最大値: ユーザが普通に投げる量はどれくらいか
- インフラの常識的な制約: reverse proxy / Cloudflare / Render 等の body サイズ制限
- 攻撃面: 認証/未認証、レート制限の有無
このプロジェクトの場合:
| 項目 | 決めた値 | 根拠 |
|---|---|---|
| 1ファイル | 20MB 維持 | iPhone HEIC/JPEG の現実的最大に余裕。10MB だと過剰圧縮されるシーンが出る |
| 1リクエスト合計 | 100MB | reverse proxy の常識的 body 上限 / 写真30枚相当 |
| ファイル数 | 30 | イベント1回分の写真 ~20-30 枚が実用上限 |
| ピクセル数/画像 | 40M ピクセル | 8K(7680×4320=33M)を超える解像度をリジェクト |
decompression bomb 攻撃
ファイルサイズが小さくても、解凍すると爆発する画像を投げる攻撃があります。
Pillow にはこの保護がデフォルトで入っていて、Image.MAX_IMAGE_PIXELS を超えた画像を Image.DecompressionBombError で弾きます。ただしデフォルト値(約 89M ピクセル)はやや甘いです。プロジェクト固有の値で明示するのが安全です:
# 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 デフォルト保護」の二重で守ります:
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")二重に守ることで「片方をうっかり外しても」セーフです。
合計サイズの早期チェック
個別ファイル検証より先に合計サイズを見ると、巨大リクエストを早期に弾けます:
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 を開けないので、ピクセル数検証はスキップ:
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月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。