Django/Security 画像アップロード、無作為に受け取るとサーバーが壊れる:セキュリティと効率を両立する完全ガイド

ウェブサービスで画像アップロードは「常にある機能」として簡単に作れます。しかしアップロードエンドポイントは 外部データがサーバー内部に入る最も直接的な通路 であり、攻撃者にとっては 最も低コストで最大の被害を狙えるポイント です。(ウェブシェルアップロード、画像パーサー脆弱性、DoS など)

この記事は「セキュリティは偏執的に、リソースは経済的に」という一見矛盾した目標を 両方満たす 方法を整理します。核心はシンプルです。

  • 検証段階ではできるだけ「読み込まない」(デコード禁止、ヘッダー/メタデータのみ)
  • 保存段階では大胆に「再作成」(TranscodingでSanitizing)

アップロードセキュリティの本質:"信頼しない、できるだけ遅く信じる"



アップロードで信頼できるものはほとんどありません。

  • ファイル名:ユーザーが変更する
  • 拡張子:好きに変更する
  • Content-Type:クライアントが送る
  • ファイル内容:攻撃者が作る

したがって戦略は 2 つに収束します。

  1. 軽いコストで高速に除外(cheap checks)
  2. 最終保存物は常にサーバーが作る(server‑generated artifact)

よくある誤解:"確実に検査したいなら最後まで読み込むべき"

セキュリティをよくしたい開発者がよく犯すミスがあります。アップロード検証段階で以下のようなコードを入れます。

img = Image.open(file)
img.verify()  # または img.load()

問題はこれが サーバーリソースを攻撃者に「前払い」で提供している 行為である点です。

なぜ危険なのか?

  • 圧縮爆弾(Decompression Bomb) 表面上は数 MB だが、デコードすると数十 GB になるファイルが来る可能性があります。load() は実際にピクセルをデコードし、メモリ/CPU を瞬時に枯渇させ DoS へとつながります。
  • 不要な I/O verify() はファイルを最後まで読む性質があり、I/O コストが大きく、後続処理では通常 seek(0) / 再オープンが必要です。

結論: アップロード「検証」段階でピクセルをデコードしないでください。 ヘッダー + メタデータ + 解像度制限だけで 1 次防御は十分に強力です。


防御は一発ではなく 3 段階:Defense in Depth



画像アップロードは「一行の if」で終わる問題ではありません。現実的なバランスを取るにはレイヤーを積む必要があります。

1 段階:拡張子は嘘だ — Magic Number で MIME 判別

profile.png という名前は何の意味もありません。ファイルの シグネチャ(Magic Number) を読み、実際のタイプを確認する必要があります。

  • ファイル全体を read() しないでください。先頭 1〜2KB で十分です。
  • ライブラリ例:python-magic(libmagic ベース)

2 段階:Pillow は「開くだけ」でまだ安全 — Lazy Loading で解像度制限

Pillow の Image.open() は一般的に 即座にピクセルをロードせずヘッダーだけをパース します。この性質を利用して、デコード/メモリ爆弾を誘発する前に 解像度(ピクセル数)でブロック できます。

  • チェックするもの:width * height <= MAX_PIXELS
  • ポイント:load() / verify() なしで size だけを見る

3 段階:最高の消毒は「新しく描く」 — Transcoding で Sanitizing

最も重要な原則です。

原本をそのまま保存しないでください。

画像にはメタデータ(EXIF)、プロファイル、スラックスペース、パーサートリックなど 「ピクセル以外の領域」 に奇妙なものが潜む可能性があります。逆に、ピクセルデータだけを抽出してサーバーが新フォーマットで再保存すれば、かなりの部分が自然に除去されます。

  • 推奨:WebP(または AVIF/JPEG) でサーバーが再エンコードして保存
  • 効果:Sanitizing + 容量最適化 + 一貫したフォーマットポリシー

実践実装:DRF Serializer(セキュリティ + メモリ経済性)

以下のコードは「検証段階でできるだけ読み込まず」「保存段階で再作成する」という哲学をそのまま取り入れています。

  • f.size は Django が既に知っているメタデータなので積極的に活用
  • Magic Number は 先頭だけ
  • Pillow は open()解像度だけ
  • 最終保存は Transcoding(WebP)
  • 各段階で seek(0) でファイルポインタを復元(失敗すると次の段階が壊れます)
from io import BytesIO

import magic
from PIL import Image, ImageOps, UnidentifiedImageError
from rest_framework import serializers

# ポリシー(サービスに合わせて調整)
MAX_SIZE = 5 * 1024 * 1024          # 5MB
MAX_PIXELS = 4_194_304              # 2048 * 2048 ≈ 4MP
ALLOWED_MIME = {"image/png", "image/jpeg", "image/webp"}

class SecureImageUploadSerializer(serializers.Serializer):
    file = serializers.ImageField()

    def validate_file(self, f):
        # [1] サイズ制限:最も安価で高速なフィルタ
        if f.size > MAX_SIZE:
            raise serializers.ValidationError("ファイルサイズが大きすぎます。")

        # [2] Magic Number で MIME 確認:拡張子/Content-Type を信用しない
        f.seek(0)
        head = f.read(2048)  # 先頭だけ
        f.seek(0)

        mime = magic.from_buffer(head, mime=True)
        if mime not in ALLOWED_MIME:
            raise serializers.ValidationError("サポートされていないファイル形式です。")

        # [3] 解像度制限:load/verify なしでヘッダーだけで size を確認
        try:
            with Image.open(f) as img:
                w, h = img.size
                if (w * h) > MAX_PIXELS:
                    raise serializers.ValidationError("画像解像度が大きすぎます。")
        except UnidentifiedImageError:
            raise serializers.ValidationError("有効な画像ではありません。")
        except Exception:
            raise serializers.ValidationError("画像検証中にエラーが発生しました。")
        finally:
            f.seek(0)

        return f

    def create(self, validated_data):
        f = validated_data["file"]

        # 最終保存物は常にサーバーが生成(Sanitizing)
        try:
            with Image.open(f) as img:
                # EXIF 回転補正(モバイルアップロードで特に重要)
                img = ImageOps.exif_transpose(img)

                # 安全で一貫した色空間に正規化
                if img.mode not in ("RGB", "RGBA"):
                    img = img.convert("RGB")

                out = BytesIO()
                img.save(out, format="WEBP", quality=85, method=6)
                out.seek(0)

                safe_bytes = out.getvalue()

                # ここで safe_bytes をストレージに保存してください。
                # - ファイル名は乱数化(UUID)
                # - ディレクトリシャーディング(例:ab/cd/uuid.webp)
                # - DB にはオリジナル名ではなくサーバー生成キーのみ保存
                return safe_bytes

        except Exception:
            raise serializers.ValidationError("画像処理中にエラーが発生しました。")

"偏執的セキュリティ" vs "リソース経済性" のバランスを取る方法

ここで重要なバランス感覚はこれです。

検証段階で貪欲にならない

検証段階はトラフィックが最も多く、攻撃者がコストなしで繰り返し呼び出せる領域です。 ここで load() のような「高価な演算」を行うと、攻撃者がサーバーコストを自由に消費できます。

  • ✅ サイズ制限 / ヘッダーベース MIME / 解像度制限
  • ❌ ピクセルデコード強制 / ファイル全体読み込み / 複数再オープン

「本当に安全」は保存段階で作る

検証段階は「除外」の段階であり、保存段階は「標準化」の段階です。 保存段階でサーバーが 新しいバイトストリームを生成 するとセキュリティと運用が容易になります。

  • フォーマット統一 → キャッシュ戦略/サムネイルパイプライン簡素化
  • メタデータ整理 → 個人情報(EXIF GPS)除去にも有利
  • 悪意あるペイロード挿入余地を縮小

さらに完璧にするためのポイント

悪意あるファイルアップロードを防御する画像

  • ファイル名は絶対に信頼しない、サーバーで生成(UUID 推奨)
  • アップロードはアプリサーバーが直接受け取らない構造も検討 (大規模サービスなら presigned URL でオブジェクトストレージ直アップロード + 非同期検査/変換)
  • 処理時間制限/ワーカー分離 画像変換は CPU を消費します。ウェブリクエスト‑レスポンス経路で長時間待たせず、ワーカー/キューへ分離するのも現実的選択です。
  • ログ/メトリクス 拒否理由(MIME 不一致、解像度超過、サイズ超過)を集計すると攻撃/悪用パターンが早く見えます。

チェックリストまとめ

  1. メモリに全ファイルを載せないread() は先頭だけ、残りはストリーミング/ファイルオブジェクトベース。
  2. 拡張子/Content-Type を信用せず Magic Number で MIME 確認
  3. 検証段階で load()/verify() でデコードせず解像度だけチェック
  4. 原本を保存せず Transcoding でサーバーが新ファイルを生成
  5. ファイルポインタは各段階で seek(0) で復元

アップロードセキュリティは「ユーザーを疑う心」から始まりますが、アップロード性能は「システムがどう動くかを理解する姿勢」から完成します。 どちらか一方だけを気にすると最終的に運用で壊れます。両方を同時に捉える構造で、アップロードポートを安全にしましょう。


関連ポストも確認してください