画像アップロードは、実装が簡単な割に穴が開きやすい機能です。
実際に必要な対策を順にまとめます。
拡張子の確認だけでは足りない
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で認可付き配信