Django/Security Загрузка изображений, если не проверять, сервер падает: полный гид по безопасности и эффективности

В веб‑сервисах загрузка изображений часто считается «всегда присутствующей» функцией, которую можно быстро реализовать. Однако эндпоинт для загрузки – это самый прямой путь внешних данных в сервер, и для злоумышленника это точка, где можно нанести максимальный ущерб при минимальных затратах (загрузка веб‑шелла, уязвимости парсера изображений, DoS и т.д.).

В этой статье мы покажем, как одновременно удовлетворить «парадокс» «безопасность – паранойя, ресурсы – экономия».

Ключевые идеи:

  • На этапе проверки – «не читаем» (запрещаем декодирование, читаем только заголовки/метаданные)
  • На этапе сохранения – «переходим к новому» (транскодируем для очистки)

Суть безопасности загрузки: «не доверяй, пока не убедишься»



Над чем можно доверять при загрузке почти ничего нет.

  • Имя файла: меняет пользователь
  • Расширение: меняет пользователь
  • Content‑Type: указывает клиент
  • Содержимое: создаёт злоумышленник

Поэтому стратегия сводится к двум пунктам.

  1. Быстрый отбор с низкой стоимостью (cheap checks)
  2. Сохраняем только серверно‑сгенерированный файл (server‑generated artifact)

Распространённое заблуждение: «чтобы проверить, надо прочитать всё»

Многие разработчики делают ошибку, вставляя в проверочный код:

img = Image.open(file)
img.verify()  # или img.load()

Проблема в том, что это предоплата ресурсов серверу злоумышленнику.

Почему опасно?

  • Раздувающийся архив (Decompression Bomb) – файл выглядит как несколько мегабайт, но после декодирования становится десятками гигабайт. load() реально декодирует пиксели, быстро истощая память/CPU и приводя к DoS.
  • Ненужный I/Overify() читает файл до конца, а после обычно требуется seek(0)/переоткрытие.

Вывод: в этапе проверки не декодируйте пиксели. Достаточно заголовков, метаданных и ограничения разрешения.


Защита – не один шаг, а 3 уровня: Defense in Depth



Загрузка изображений не решается «одним if». Нужно строить слои.

1 уровень: Расширение – ложь, MIME по Magic Number

Имя profile.png ничего не говорит. Нужно читать сигнатуру (Magic Number).

  • Не считывайте весь файл. 1–2 КБ достаточно.
  • Библиотека: python-magic (libmagic).

2 уровень: Pillow – «только открытие» и ограничение разрешения

Image.open() обычно не загружает пиксели сразу, а только парсит заголовок. Это позволяет блокировать по разрешению до декодирования.

  • Проверяем: width * height <= MAX_PIXELS.
  • Не используем load()/verify().

3 уровень: Лучшее «очищение» – «перерисовать» (Transcoding)

Самый важный принцип.

Не сохраняйте оригинал.

Изображения могут скрывать вредоносные данные в метаданных (EXIF), профилях, «пикселях» и т.д. Если сервер извлечёт только пиксели и сохранит в новом формате, большинство угроз исчезнет.

  • Рекомендуется WebP (или AVIF/JPEG) для пересохранения.
  • Это обеспечивает очистку + оптимизацию размера + единообразие формата.

Практическая реализация: DRF Serializer (безопасность + экономия памяти)

Ниже код, который воплощает философию «не читаем в проверке, пересоздаём в сохранении».

  • f.size – метаданные, которые знает Django.
  • Magic Number – читаем только начало.
  • Pillow – только size.
  • Финальное сохранение – 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          # 5 МБ
MAX_PIXELS = 4_194_304              # 2048 × 2048 ≈ 4 МП
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] Проверка MIME по Magic Number
        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] Ограничение разрешения без декодирования
        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"]

        # Финальное сохранение – сервер генерирует новый файл (очистка)
        try:
            with Image.open(f) as img:
                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
                # - используйте шардирование каталогов
                # - в БД храните только ключ, а не оригинальное имя
                return safe_bytes

        except Exception:
            raise serializers.ValidationError("Ошибка при обработке изображения.")

Как сбалансировать «паранойю» и экономию ресурсов

Ключевой баланс:

Не переусердствуйте в проверке

Проверка – это зона с наибольшим трафиком и возможностью атаки. Любые дорогие операции (например, load()) позволяют злоумышленнику «платить» за ресурсы сервера.

  • ✅ Ограничение размера / MIME по заголовку / ограничение разрешения
  • ❌ Декодирование пикселей / чтение всего файла / многократное открытие

«Настоящая» безопасность – в сохранении

Проверка – это фильтр, а сохранение – стандартизация. Если сервер генерирует новый поток байтов, безопасность и эксплуатация упрощаются.

  • Единый формат → упрощённая стратегия кэширования и генерации превью
  • Очистка метаданных → удаление личных данных (EXIF GPS)
  • Снижение риска внедрения вредоносных payload

Полный чек‑лист

  1. Не загружайте файл полностью в память. read() – только начало, остальное – поток.
  2. Не доверяйте расширению/Content‑Type, проверяйте Magic Number.
  3. В проверке не используйте load()/verify(), проверяйте только разрешение.
  4. Не сохраняйте оригинал, пересохраняйте (Transcoding).
  5. В каждом шаге возвращайте указатель seek(0).

Безопасность загрузки начинается с подозрения, а эффективность – с понимания, как работает система. Соблюдая оба принципа, вы создадите надёжный порт для изображений.


Смотрите также