Перейти к содержанию

Безопасная загрузка изображений

Текст заметки проходит проверку длины и выводится в правильном контексте. Теперь разрешим владельцу загрузить небольшое изображение. Файл имеет несколько независимых характеристик: имя, размер, объявленный тип и содержимое. Значение photo.png не доказывает, что внутри находится допустимое изображение.

В security-lab поддерживается только PNG до 512 КиБ и одного миллиона пикселей. Результат урока — ограниченное преобразование изображения в новую серверную запись. Файлы остаются вне публичной папки; способ их выдачи после проверки владельца появится следующим уроком.

Небольшой договор

Продукту не нужен универсальный архив или редактор документов. Поэтому допустимый формат можно определить строго. Отказ JPEG в нашем примере не является утверждением о его опасности; это просто граница реализованной функции.

Предел сжатого файла и число пикселей решают разные задачи. Маленькая входная строка может описывать большое распакованное изображение. До полной обработки нужно проверить оба ограничения. Декодер также остаётся зависимостью, которую необходимо обновлять и наблюдать.

Общий MAX_CONTENT_LENGTH увеличивается с начальных 16384 байт до 1 МиБ, чтобы вместить multipart с изображением и служебным токеном. Ограничения названия и текста заметки при этом не меняются. Весь запрос, отдельный файл и его распакованный результат имеют разные пределы.

Проверка содержимого

Ниже полный helper media.py. Он получает байты, проверяет PNG, ограничивает геометрию и создаёт новое RGB-изображение. Для примера используется Pillow 12.3; фактическая совместимость окружения проверяется при будущей практике.

from io import BytesIO
import warnings
from PIL import Image, UnidentifiedImageError

def normalize_png(raw):
    if not raw or len(raw) > 512 * 1024:
        raise ValueError("Размер изображения")
    try:
        with warnings.catch_warnings():
            warnings.simplefilter("error", Image.DecompressionBombWarning)
            with Image.open(BytesIO(raw), formats=["PNG"]) as image:
                width, height = image.size
                if width * height > 1_000_000 or image.n_frames != 1:
                    raise ValueError("Геометрия изображения")
                image.verify()
            with Image.open(BytesIO(raw), formats=["PNG"]) as image:
                clean = image.convert("RGB")
                clean.info.clear()
                output = BytesIO()
                clean.save(output, format="PNG")
    except (UnidentifiedImageError, OSError, Image.DecompressionBombError,
            Image.DecompressionBombWarning) as error:
        raise ValueError("Неверное изображение") from error
    result = output.getvalue()
    if len(result) > 3_000_000:
        raise ValueError("Размер результата")
    return result

Image.open сначала определяет структуру; verify проверяет целостность без обычного дальнейшего использования объекта. Поэтому для преобразования данные открываются снова. Поведение описано в справочнике Pillow Image.

Новое изображение не использует присланное имя или произвольный формат результата. Очистка info убирает перенесённые метаданные перед сохранением. Это не универсальная антивирусная проверка: мы ограничили конкретную функцию, а не научились принимать любые документы.

Преобразование в RGB также меняет некоторые свойства изображения, включая прозрачность. Такое ограничение должно быть известно продукту. Для аватара или схемы с прозрачным фоном потребовалась бы другая согласованная политика; её нельзя считать автоматически реализованной этим helper.

Новое серверное имя

Исходное filename остаётся недоверенной строкой. Оно может содержать путь, совпадать с существующим файлом или раскрывать лишние сведения. В нашей модели имя вообще не участвует в размещении: приложение создаёт UUID и окончание .png.

import uuid

file_id = uuid.uuid4().hex
storage_name = file_id + ".png"
target = PRIVATE_MEDIA / storage_name
target.write_bytes(normalized)

Здесь normalized является результатом normalize_png, а PRIVATE_MEDIA — серверной папкой instance/private_media. Фрагмент выполняется после входа, проверки CSRF и чтения одного разрешённого файла. В окончательном обработчике добавлена запись владельца и очистка при неуспешном сохранении в базе.

Случайный ID уменьшает конфликт имён, но не предоставляет полномочие. Человек, узнавший этот ID, всё равно должен пройти проверку доступа при чтении. Следующий урок сохранит эту границу.

Ресурс и количество

Один допустимый файл не ограничивает множество загрузок. Для маленького стенда разрешим владельцу не более пяти записей. Условие количества проверяется в операции вставки базы, чтобы оно не зависело только от предварительного чтения.

Даже такой предел не рассчитывает общую пропускную способность сервиса. Декодирование нескольких одновременных изображений требует памяти и времени. Для публичного продукта может понадобиться отдельный рабочий процесс, очередь и ограничение нагрузки; они не создаются из одной проверки пикселей.

Если файл записан, а вставка базы отказала, его нужно убрать. Если процесс завершился в промежутке, может остаться сиротский файл. Сопровождение должно уметь сверять хранилище и записи; эту связь рассмотрим в жизненном цикле данных.

Отказ и результат

Переименованный текстовый файл должен быть отклонён, даже если его MIME объявлен image/png. Слишком большой PNG также отклоняется. Корректный маленький однокадровый файл создаёт новую запись с серверным именем, а исходное имя не становится URL.

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

Принципы ограничения, хранения вне webroot и выдачи через контролируемый обработчик описаны в OWASP File Upload. Наш небольшой PNG-режим иллюстрирует часть этих принципов, сохраняя явно обозначенные ограничения.

Тело формы и подтверждение записи

Форма загрузки имеет один файл image и один csrf_token. Если посетитель передал два файла под одинаковым именем, выбор только первого скрывал бы неоднозначность. Финальный обработчик проверяет getlist и допускает ровно одно содержимое.

Атрибут accept помогает выбору в браузере, но не ограничивает HTTP-клиент. В том же порядке проверяются фактические байты. Корректный PNG с необычным исходным окончанием всё равно остаётся PNG; наш сервер не использует filename для решения о каталоге.

Новая запись относится к владельцу, а не автоматически к одной заметке. Если продукт позже начнёт прикреплять изображение к объекту, понадобится проверка права и на вложение, и на заметку. Одного существования двух ID недостаточно, чтобы разрешить связь между ними.

Сохранение в базе условно разрешено только при количестве меньше пяти. Такое условие применяется в INSERT, поэтому несколько параллельных запросов не обходят квоту через одинаковое предварительное чтение. Однако запись файла и SQL всё ещё являются двумя операциями, которые нужно согласовать при ошибке.

В финальном исходнике новый файл открывается с режимом xb. Он не заменяет уже существующее имя при конфликте. Если дальнейшая операция отказала, удаляется только файл, созданный текущим запросом. Удаление чужого прежнего файла в обработке такого конфликта было бы новой ошибкой.

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

Наконец, отсутствие публичной ссылки после отказа не доказывает отсутствие файла на диске. Для контрольного сценария рассматриваются оба места состояния: запись uploads и приватная папка. Это соединяет результат формы с фактической операцией хранения.

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