5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像アップロードAPIのセキュリティ対策を、実装レベルで振り返る

5
Posted at

アップロードされた画像を受け取るAPIで、最低限入れておきたい入口の対策をチェックリストにしました。各項目は「何を確かめるか、どの攻撃を想定するか、コード、失敗時に返すステータス」の順で書いています。全部を入れる必要はないと思うので、気になる項目だけ拾ってもらえれば十分です。

コードはFastAPIとPillowの説明用の最小例です。すべて手元で実行して、後ろの表の出力は実際の結果をそのまま載せています。

動作確認の環境(2026年10月17日)は次のとおりです。

  • macOS、Python 3.14.6
  • fastapi 0.143.3、starlette 1.8.0、python-multipart 0.0.32
  • pillow 13.0.0
  • pytest 9.1.1、httpx 0.28.1

ライブラリのバージョンで挙動が変わりうる箇所は、その都度書いています。

チェックリスト一覧

# 確かめること 想定する攻撃・事故 失敗時の応答
1 リクエスト本体のバイト数の上限 巨大ファイルでメモリ・ディスクを埋める 413
2 形式の許可リスト(中身で判定) 拡張子偽装、想定外のデコーダーの利用 415
3 幅×高さの上限(デコード前) decompression bomb 422
4 フレーム数 アニメーションで1フレーム分の検査をすり抜ける 422
5 デコード失敗の扱い 壊れた・途中で切れたファイル 400
6 再エンコードとメタデータの除去 EXIFの位置情報の漏れ、画像に紛れた余計なデータ (応答なし、保存時の処理)
7 ファイル名と保存先 パストラバーサル、上書き、公開ディレクトリへの配置 (応答なし、保存時の処理)
8 処理時間の上限 デコードに時間のかかる画像 この記事では未実装

全体の流れは次のとおりです。1がリクエストの入口、2から5が画像の検査、6と7が保存の直前です。

リクエスト → [1 体積] → [2 形式] → [3 画素数] → [4 フレーム] → [5 デコード] → [6 再エンコード] → [7 保存]
              413        415        422         422          400

ステータスの割り当ては好みが分かれるところです。ここでは「サイズ超過は413、形式不正は415、中身は読めるが受け付けない条件に反するものは422、読めないものは400」としました。画素数超過を400にするAPIも、413にするAPIもあります。クライアントが見分けられることのほうが大事だと考えています。

1. リクエスト本体のサイズ上限(413)

Content-Length を見るだけでは足りません。chunked転送ではそのヘッダー自体が付かないためです。受け取ったバイト数を数えて、超えた時点で止めます。

import io
import uuid
from pathlib import Path

from fastapi import FastAPI, HTTPException, UploadFile
from PIL import Image, ImageOps

MAX_BODY = 5 * 1024 * 1024        # 5MiB(説明用の値)
MAX_PIXELS = 25_000_000           # 25MP(説明用の値)
ALLOWED = {"JPEG": ".jpg", "PNG": ".png", "WEBP": ".webp"}
UPLOAD_DIR = Path("uploads")      # 公開ディレクトリの外に置く

Image.MAX_IMAGE_PIXELS = MAX_PIXELS   # 2倍を超えるとopen時に例外、超過〜2倍は警告のみ
class BodyTooLarge(Exception):
    pass


class BodySizeLimit:
    """ASGIミドルウェア。Content-Lengthと、実際に受け取ったバイト数の両方を見る"""

    def __init__(self, app, max_body: int = MAX_BODY):
        self.app, self.max_body = app, max_body

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            return await self.app(scope, receive, send)
        cl = dict(scope["headers"]).get(b"content-length")
        if cl is not None and (not cl.isdigit() or int(cl) > self.max_body):
            return await self._reject(send)

        got, exceeded, rejected = 0, False, False

        async def limited_receive():
            nonlocal got, exceeded
            msg = await receive()
            if msg["type"] == "http.request":
                got += len(msg.get("body", b""))
                if got > self.max_body:
                    exceeded = True
                    raise BodyTooLarge
            return msg

        async def guarded_send(msg):
            # 例外はフォーム解析側で400に変換されることがあるので、
            # 上限超過の印が付いていたら、アプリの応答を413で置き換える
            nonlocal rejected
            if exceeded:
                if not rejected:
                    rejected = True
                    await self._reject(send)
                return
            await send(msg)

        try:
            await self.app(scope, limited_receive, guarded_send)
        except BodyTooLarge:
            if not rejected:
                await self._reject(send)

    async def _reject(self, send):
        await send({"type": "http.response.start", "status": 413,
                    "headers": [(b"content-type", b"application/json")]})
        await send({"type": "http.response.body",
                    "body": b'{"detail":"payload too large"}'})

上限超過のときは、アプリ側ではなくミドルウェアが413を返します。例外を投げるだけの形にしないのには理由があります。最初に書いた版は、BodyTooLarge を投げて外側で受けるだけでした。これだと Content-Length の付いたリクエストは413になるのですが、chunkedで送るとFastAPIのフォーム解析が例外を飲み込み、400 There was an error parsing the body が返りました。上のコードは、上限超過の印が付いたらアプリの応答を413で置き換える形にして直したものです。chunkedのマルチパートでも413になることをテストで確かめています。

前段にリバースプロキシを置けるなら、そちらでも同じ上限をかけておくと、アプリまで大きなリクエストが届かなくなります。

2. 形式は中身で判定して、許可リストで絞る(415)

拡張子と Content-Type はクライアントが好きに書けます。判断には使いません。Image.open の formats を指定すると、Pillowは先頭バイトを見て許可した形式のデコーダーだけを試します。許可リスト外は UnidentifiedImageError になり、デコーダーに触れる前に弾けます。

img = Image.open(io.BytesIO(data), formats=list(ALLOWED))   # ALLOWED = JPEG / PNG / WEBP

SVGは許可リストに入れていません。SVGはXMLなので、スクリプトを埋め込めます。サイトのオリジンから配信すると、XSSの経路になりえます。実際に a.png の名前で送ったSVGは415でした。

3. 幅×高さの上限をデコード前にかける(422)

ファイルが小さくても、展開後が巨大な画像があります。12000×12000のグレースケールPNGは、手元で作ると約137KB(139,863バイト)でしたが、デコードすると1億4,400万画素です。体積の上限(1)では止まりません。

Pillowの Image.MAX_IMAGE_PIXELS には、気をつけたい挙動があります。

  • 上限を超えると DecompressionBombWarning(警告)
  • 上限の2倍を超えると DecompressionBombError(例外、open の時点)

今回の実行では、上限を25,000,000にして、6000×6000(36メガピクセル)は警告だけで開けました。8000×8000(64メガピクセル)は DecompressionBombError でした。警告は例外ではないので、何もしなければ素通りします。そこで、Image.open が返すヘッダー上の幅と高さを自分で見ています。open は画素のデコードを load() まで遅延するため、この時点ではまだメモリを使っていません。

w, h = img.size
if w * h > MAX_PIXELS:
    raise HTTPException(422, "image dimensions too large")

警告を例外に変える warnings.simplefilter("error", ...) も使えますが、warnings のフィルターはプロセス全体の設定です。スレッドで並列に処理するAPIでは、他のコードの警告の扱いまで変わります。寸法を自分で比べるほうが影響範囲は小さいです。

上限の値は、読み取りたい画像に必要な解像度に余裕を持たせて決めてください。この記事の25メガピクセルは説明用です。スマートフォンの高解像度モードの写真は、これを超えることがあります。

4. フレーム数を見る(422)

img.size は1フレーム分の大きさです。アニメーションWebPやGIFは、1フレームが小さくてもフレーム数で総量が膨らみます。単一画像しか想定しないAPIなら、複数フレームはまとめて断るのが簡単です。

if getattr(img, "n_frames", 1) > 1:
    raise HTTPException(422, "multi-frame images are not accepted")

アニメーションに対応する場合は、フレーム数と総画素数の両方に上限が必要です。

5. デコードの失敗を握りつぶさない(400)

ヘッダーが正しくても、本体が途中で切れていることがあります。load() の失敗は呼び出し側で受けて、500ではなく400にします。500にすると、クライアントの入力の問題がサーバーの障害として記録されてしまいます。

try:
    img = ImageOps.exif_transpose(img)
    img.load()
except (OSError, ValueError, SyntaxError):
    raise HTTPException(400, "corrupted image")

捕まえる例外の種類はデコーダーによって違います(PNGの壊れ方によっては SyntaxError が出ます)。広く Exception で受けると、自分のバグまで400にしてしまうので、ここでは3種類に絞りました。ログには、画像の中身ではなく、サイズと検出した形式を残す程度にしておくのが無難だと思います。

6. 再エンコードして、メタデータを持ち越さない

スマートフォンで撮った写真のEXIFには、機種名や撮影日時、場所によってはGPSの座標が入っています。アップロードされたものをそのまま保存して返すと、投稿者の位置が第三者に見える可能性があります。ピクセルだけを新しい画像にコピーして保存し直せば、EXIF、ICCプロファイル、テキストチャンクは持ち越されません。

ただし、1つ落とし穴があります。EXIFの Orientation を落とすと、スマートフォンの縦向き写真が横倒しになります。先にピクセルへ反映してから捨てます。

img = ImageOps.exif_transpose(img)    # 向きを反映
...
has_alpha = img.mode in ("RGBA", "LA") or "transparency" in img.info
mode = "RGBA" if has_alpha else "RGB"
clean = Image.frombytes(mode, img.size, img.convert(mode).tobytes())
out = io.BytesIO()
clean.save(out, format=fmt)     # fmtは検出した形式

試しに、Orientation=6(90度回転)と機種名を入れた40×20のJPEGを送りました。保存された画像は20×40で、getexif() は空でした。

この処理にはコストがあります。

  • JPEGは再圧縮で劣化します(quality= を指定しない場合のPillowのデフォルトは75です)。OCRなど画質が精度に効く用途では、値を決めて比べてください
  • グレースケールはRGBになります。16bit PNGは8bitになります
  • ICCプロファイルを捨てるので、色の見え方が変わることがあります

この項目は、一般的な推奨としてここに入れたもので、自分たちのOCR APIで入れているとは言っていません。

7. ファイル名と保存先

クライアントが送る filename は、パスの区切りや .. を含みえます。保存パスには使いません。名前はサーバー側で作り、拡張子も検出した形式から決めます。保存先は静的配信のルートの外に置きます。

name = f"{uuid.uuid4().hex}{ALLOWED[fmt]}"
(UPLOAD_DIR / name).write_bytes(body)

../../etc/passwd.png というファイル名で送ったところ、保存名は32桁のhexと .png で、区切り文字は入りませんでした。元のファイル名を表示用に残したいなら、DBのカラムに入れて、パスには使わないようにします。保存した画像をそのまま配信するときは、検出した形式から Content-Type を付けて、X-Content-Type-Options: nosniff も付けておくと、ブラウザの推測による誤解釈を避けられます(この2点はこの記事のコードでは確かめていません)。

8. 処理時間の上限(この記事では未実装)

寸法と体積を絞っても、デコードに時間のかかる細工をされた画像は作れます。ここのコードは、時間の上限を入れていません。エンドポイントを def にしているのは、デコードでイベントループを止めないためで、時間制限の代わりにはなりません。asyncio.wait_for でスレッド内の処理を待っても、スレッド自体は止まらないからです。時間を切るなら、デコードを別プロセスに出して、タイムアウトで終了させる形が必要です。

エンドポイント全体

ここまでを1つにまとめたものです。

def inspect_and_reencode(data: bytes) -> tuple[bytes, str, tuple[int, int]]:
    # 1) 先頭バイトで形式を判定し、許可リスト外はデコーダーに渡さない
    try:
        img = Image.open(io.BytesIO(data), formats=list(ALLOWED))
    except Image.DecompressionBombError:
        raise HTTPException(422, "image dimensions too large")
    except Image.UnidentifiedImageError:
        raise HTTPException(415, "unsupported image format")

    # 2) ヘッダーの寸法で判定(ここではまだ画素をデコードしていない)
    w, h = img.size
    if w * h > MAX_PIXELS:
        raise HTTPException(422, "image dimensions too large")
    if getattr(img, "n_frames", 1) > 1:
        raise HTTPException(422, "multi-frame images are not accepted")

    fmt = img.format
    # 3) 実際のデコード。壊れた/切れたファイルはここで落ちる
    try:
        img = ImageOps.exif_transpose(img)   # 向きをピクセルに反映してからEXIFを捨てる
        img.load()
    except (OSError, ValueError, SyntaxError):
        raise HTTPException(400, "corrupted image")

    # 4) ピクセルだけ新しい画像にコピーして再エンコード(EXIF/ICC/テキストチャンクは持ち越さない)
    has_alpha = img.mode in ("RGBA", "LA") or "transparency" in img.info
    mode = "RGBA" if has_alpha else "RGB"
    clean = Image.frombytes(mode, img.size, img.convert(mode).tobytes())
    out = io.BytesIO()
    clean.save(out, format=fmt)
    return out.getvalue(), fmt, clean.size
app = FastAPI()
app.add_middleware(BodySizeLimit)


@app.post("/images")
def upload(file: UploadFile):          # def にしてデコードをスレッドプールで実行する
    data = file.file.read(MAX_BODY + 1)
    if len(data) > MAX_BODY:
        raise HTTPException(413, "payload too large")
    body, fmt, size = inspect_and_reencode(data)

    # クライアントのファイル名は使わない。拡張子も検出した形式から決める
    name = f"{uuid.uuid4().hex}{ALLOWED[fmt]}"
    UPLOAD_DIR.mkdir(exist_ok=True)
    (UPLOAD_DIR / name).write_bytes(body)
    return {"id": name, "format": fmt, "width": size[0], "height": size[1], "bytes": len(body)}

実行結果

pytest から TestClient で送った結果です(上のコードをそのまま実行)。

送ったもの 結果
64×48の普通のPNG 200、format: PNG、再エンコード後160バイト
中身がPHPのテキスト(名前は evil.png、image/png) 415 unsupported image format
中身がSVG(名前は a.png) 415
中身がGIF(名前は a.png、image/png) 415
6MiB超のデータ(Content-Length あり) 413 payload too large
7MiBをchunkedで送信(Content-Length なし) 413 payload too large
12000×12000のPNG(ファイル139,863バイト、1億4,400万画素) 422 image dimensions too large
6000×6000のPNG(3,600万画素、警告の範囲) 422(DecompressionBombWarning を出して、自前の判定で拒否)
途中で切れた400×400のPNG 400 corrupted image
3フレームのロスレスWebP 422 multi-frame images are not accepted
../../etc/passwd.png の名前の正常なPNG 200、保存名はランダムなhex + .png
Orientation=6と機種名つきの40×20 JPEG 200、保存後は20×40でEXIFは空

GIFは許可リストに入れていないので415ですが、「拡張子とContent-Typeがpng」でも中身で判定していることの確認になっています。

適用条件と、このコードがやっていないこと

使える条件と、使えない条件を分けて書きます。

前提にしていること

  • 受け付けるのは静止画のJPEG、PNG、WebPだけです。HEIC(iPhoneの標準設定)やAVIFは通りません。受け付けるなら、pillow-heif のようなプラグインと、そのデコーダーの脆弱性への追従が必要です
  • 1リクエストに1枚です。複数枚のマルチパートは、合計サイズと枚数の上限を別に決める必要があります
  • アプリの前段にリバースプロキシがあり、そちらでもボディの上限をかけられる環境を想定しています
  • 数値(5MiB、25メガピクセル)は説明用です。用途に合わせて決めてください

このコードがカバーしていないこと

  • マルウェアのスキャン。画像として正しい形式でも、末尾に別のデータを足したファイル(polyglot)は作れます。再エンコードで多くは落ちますが、ウイルス対策の代わりにはなりません
  • 認証・認可、レート制限、同じ画像の再送への対処。別の話なので、ここでは扱いません
  • CDN、WAF、オブジェクトストレージ側の設定
  • デコーダー自体の脆弱性。形式の絞り込みとバージョンの固定・更新で減らせますが、ゼロにはなりません
  • 処理時間の上限(8)
  • Pillow 13.0.0で確認した挙動です。他のバージョンでは、警告と例外の閾値や、例外の種類が違う可能性があります。手元のバージョンで同じテストを流してください
  • iPhoneの写真を端末側で縮めてから送る話は、別の記事にしています

やってみて

こういった対策は、自分たちが作ったOCR APIでも一部入れました。25メガピクセルの上限、formats の指定、フレーム数の確認、体積の上限などです。開発の途中で、あとから追加したルートにだけ体積の上限が効いていないことがありました。リリース前の統合テストで見つかって修正しましたが、「上限を実装した」と「全部のルートで効いている」は別だと感じました。上限のテストは、ルートの一覧から作って全部に流すようにしておくと、同じことは起きにくくなります。

最後に

全部を一度に入れる必要はありません。まずは1(体積)、2(形式)、3(画素数)の3つを入れて、上のテストの要領で、実際に大きい画像や偽装したファイルを送って確かめてみてください。

参考文献

この記事は、上のZenn記事を元に、チェックリスト形式で書き直し、コードも再実行したものです。

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?