開発・技術選定

画像アップロードを安全に扱う — 検証・リサイズ・配信の実装

画像アップロードは、実装が簡単な割に穴が開きやすい機能です。
実際に必要な対策を順にまとめます。

拡張子の確認だけでは足りない

if not filename.endswith((".jpg", ".png")):   # これだけでは不十分
    raise ValidationError("画像を選んでください")

evil.php.jpg のような名前や、拡張子を偽装したファイルが通ります。
中身を読んで判定します。

from PIL import Image

def validate_image(f):
    try:
        img = Image.open(f)
        img.verify()                 # 壊れた画像・偽装を弾く
    except Exception:
        raise ValidationError("画像として読み込めません")
    if img.format not in {"JPEG", "PNG", "WEBP"}:
        raise ValidationError("対応していない形式です")
    f.seek(0)                        # verify 後は必ず先頭に戻す

verify() の後にファイルポインタを戻し忘れると、保存時に空になります。

サイズ制限は2段構え

MAX_BYTES = 5 * 1024 * 1024
MAX_PIXELS = 8000 * 8000

if f.size > MAX_BYTES:
    raise ValidationError("5MBを超えています")
if img.width * img.height > MAX_PIXELS:
    raise ValidationError("画像の解像度が大きすぎます")

ピクセル数の制限が重要です。ファイルサイズが小さくても、
展開すると巨大になる画像(圧縮爆弾)でメモリを食い潰されます。

Nginx 側でも上限をかけ、アプリに届く前に切ります。

client_max_body_size 5M;

保存時にリサイズして再エンコード

元ファイルをそのまま保存せず、開いて描き直して保存します。
これで埋め込まれた不正なデータが落ちます。

img = Image.open(f)
img = img.convert("RGB")             # 透過・カラープロファイルを正規化
img.thumbnail((1600, 1600))          # 縦横比を保って縮小
img.save(dest, "JPEG", quality=85, optimize=True)

Exif も落ちるので、位置情報が意図せず公開される事故も同時に防げます。

ファイル名は信用しない

import uuid, pathlib

ext = ".jpg"
name = f"{uuid.uuid4().hex}{ext}"    # 元の名前は使わない

元のファイル名をそのまま使うと、パス的な文字列を含む名前で
意図しない場所に書かれる可能性があります。

配信を制御する

非公開の画像を、推測可能なURLでそのまま置いてはいけません。
アクセス制御はアプリで判定し、実配信は Nginx に任せます。

def media(request, key):
    obj = get_object_or_404(Upload, key=key)
    if obj.owner != request.user:
        raise Http404
    res = HttpResponse()
    res["X-Accel-Redirect"] = f"/protected/{obj.path}"   # Nginx が配信
    res["Content-Type"] = ""
    return res
location /protected/ {
    internal;                 # 外から直接叩けない
    alias /srv/app/media/;
}

認可はアプリ、転送は Nginx。この分担が最も効率的でした。

まとめ

  • 拡張子ではなく中身で判定する(Image.verify()
  • ファイルサイズとピクセル数の両方を制限する
  • 開いて再保存する(不正データと Exif が落ちる)
  • ファイル名は自分で生成する
  • 非公開画像は X-Accel-Redirect で認可付き配信