# Django/Security 画像アップロード、無作為に受け取るとサーバーが壊れる:セキュリティと効率を両立する完全ガイド ウェブサービスで画像アップロードは「常にある機能」として簡単に作れます。しかしアップロードエンドポイントは **外部データがサーバー内部に入る最も直接的な通路** であり、攻撃者にとっては **最も低コストで最大の被害を狙えるポイント** です。(ウェブシェルアップロード、画像パーサー脆弱性、DoS など) この記事は「セキュリティは偏執的に、リソースは経済的に」という一見矛盾した目標を **両方満たす** 方法を整理します。核心はシンプルです。 * **検証段階ではできるだけ「読み込まない」**(デコード禁止、ヘッダー/メタデータのみ) * **保存段階では大胆に「再作成」**(TranscodingでSanitizing) --- ## アップロードセキュリティの本質:"信頼しない、できるだけ遅く信じる" {#sec-98341e6a33e1} アップロードで信頼できるものはほとんどありません。 * ファイル名:ユーザーが変更する * 拡張子:好きに変更する * Content-Type:クライアントが送る * ファイル内容:攻撃者が作る したがって戦略は 2 つに収束します。 1. **軽いコストで高速に除外**(cheap checks) 2. **最終保存物は常にサーバーが作る**(server‑generated artifact) --- ## よくある誤解:"確実に検査したいなら最後まで読み込むべき" {#sec-f9b6b6b99eb2} セキュリティをよくしたい開発者がよく犯すミスがあります。アップロード検証段階で以下のようなコードを入れます。 ```python img = Image.open(file) img.verify() # または img.load() ``` 問題はこれが **サーバーリソースを攻撃者に「前払い」で提供している** 行為である点です。 ### なぜ危険なのか? {#sec-7ed56ee48a1d} * **圧縮爆弾(Decompression Bomb)** 表面上は数 MB だが、デコードすると数十 GB になるファイルが来る可能性があります。`load()` は実際にピクセルをデコードし、メモリ/CPU を瞬時に枯渇させ DoS へとつながります。 * **不要な I/O** `verify()` はファイルを最後まで読む性質があり、I/O コストが大きく、後続処理では通常 `seek(0)` / 再オープンが必要です。 **結論:** アップロード「検証」段階でピクセルをデコードしないでください。 **ヘッダー + メタデータ + 解像度制限**だけで 1 次防御は十分に強力です。 --- ## 防御は一発ではなく 3 段階:Defense in Depth {#sec-5756153c302b} 画像アップロードは「一行の if」で終わる問題ではありません。現実的なバランスを取るにはレイヤーを積む必要があります。 ### 1 段階:拡張子は嘘だ — Magic Number で MIME 判別 {#sec-bbad131e1332} `profile.png` という名前は何の意味もありません。ファイルの **シグネチャ(Magic Number)** を読み、実際のタイプを確認する必要があります。 * ファイル全体を `read()` しないでください。**先頭 1〜2KB で十分**です。 * ライブラリ例:`python-magic`(libmagic ベース) ### 2 段階:Pillow は「開くだけ」でまだ安全 — Lazy Loading で解像度制限 {#sec-f0a8fb4bda8a} Pillow の `Image.open()` は一般的に **即座にピクセルをロードせずヘッダーだけをパース** します。この性質を利用して、デコード/メモリ爆弾を誘発する前に **解像度(ピクセル数)でブロック** できます。 * チェックするもの:`width * height <= MAX_PIXELS` * ポイント:`load()` / `verify()` なしで `size` だけを見る ### 3 段階:最高の消毒は「新しく描く」 — Transcoding で Sanitizing {#sec-398d6ae888a9} 最も重要な原則です。 > 原本をそのまま保存しないでください。 画像にはメタデータ(EXIF)、プロファイル、スラックスペース、パーサートリックなど **「ピクセル以外の領域」** に奇妙なものが潜む可能性があります。逆に、ピクセルデータだけを抽出してサーバーが新フォーマットで再保存すれば、かなりの部分が自然に除去されます。 * 推奨:**WebP(または AVIF/JPEG)** でサーバーが再エンコードして保存 * 効果:**Sanitizing + 容量最適化 + 一貫したフォーマットポリシー** --- ## 実践実装:DRF Serializer(セキュリティ + メモリ経済性) {#sec-7b99e0ec685d} 以下のコードは「検証段階でできるだけ読み込まず」「保存段階で再作成する」という哲学をそのまま取り入れています。 * `f.size` は Django が既に知っているメタデータなので積極的に活用 * Magic Number は **先頭だけ** * Pillow は `open()` で **解像度だけ** * 最終保存は **Transcoding(WebP)** * 各段階で `seek(0)` でファイルポインタを復元(失敗すると次の段階が壊れます) ```python 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 "リソース経済性" のバランスを取る方法 {#sec-7ec5087de2e9} ここで重要なバランス感覚はこれです。 ### 検証段階で貪欲にならない {#sec-5c32bd66af4d} 検証段階はトラフィックが最も多く、攻撃者がコストなしで繰り返し呼び出せる領域です。 ここで `load()` のような「高価な演算」を行うと、攻撃者がサーバーコストを自由に消費できます。 * ✅ サイズ制限 / ヘッダーベース MIME / 解像度制限 * ❌ ピクセルデコード強制 / ファイル全体読み込み / 複数再オープン ### 「本当に安全」は保存段階で作る {#sec-a49dab56fd86} 検証段階は「除外」の段階であり、保存段階は「標準化」の段階です。 保存段階でサーバーが **新しいバイトストリームを生成** するとセキュリティと運用が容易になります。 * フォーマット統一 → キャッシュ戦略/サムネイルパイプライン簡素化 * メタデータ整理 → 個人情報(EXIF GPS)除去にも有利 * 悪意あるペイロード挿入余地を縮小 --- ## さらに完璧にするためのポイント {#sec-733fb04d797a} ![悪意あるファイルアップロードを防御する画像](/media/editor_temp/6/60e76b29-7657-4042-8e5b-3b7b2670a4c7.png) * **ファイル名は絶対に信頼しない、サーバーで生成**(UUID 推奨) * **アップロードはアプリサーバーが直接受け取らない構造も検討** (大規模サービスなら presigned URL でオブジェクトストレージ直アップロード + 非同期検査/変換) * **処理時間制限/ワーカー分離** 画像変換は CPU を消費します。ウェブリクエスト‑レスポンス経路で長時間待たせず、ワーカー/キューへ分離するのも現実的選択です。 * **ログ/メトリクス** 拒否理由(MIME 不一致、解像度超過、サイズ超過)を集計すると攻撃/悪用パターンが早く見えます。 --- ## チェックリストまとめ {#sec-ab5708035e92} 1. **メモリに全ファイルを載せない**。`read()` は先頭だけ、残りはストリーミング/ファイルオブジェクトベース。 2. **拡張子/Content-Type を信用せず Magic Number で MIME 確認** 3. **検証段階で `load()`/`verify()` でデコードせず解像度だけチェック** 4. **原本を保存せず Transcoding でサーバーが新ファイルを生成** 5. **ファイルポインタは各段階で `seek(0)` で復元** アップロードセキュリティは「ユーザーを疑う心」から始まりますが、アップロード性能は「システムがどう動くかを理解する姿勢」から完成します。 どちらか一方だけを気にすると最終的に運用で壊れます。両方を同時に捉える構造で、アップロードポートを安全にしましょう。 --- **関連ポストも確認してください** - [[ウェブでファイルアップロードを簡単にする] Dropzone.js 完全攻略ガイド](/ko/whitedec/2025/12/24/dropzone-js-complete-guide/)