Прямой ответ
Загрузка файлов — один из немногих векторов, где пользователь передаёт серверу произвольные байты. Основные риски: выполнение загруженного кода, подмена типа файла, выход за пределы каталога и отражённое выполнение скрипта в браузере.
Проверка типа по расширению не работает: расширение задаёт клиент. Надёжный способ — проверка магических байтов, то есть сигнатуры формата в начале файла:
SIGNATURES = {
b"\x89PNG\r\n\x1a\n": "image/png",
b"\xff\xd8\xff": "image/jpeg",
b"PK\x03\x04": "application/zip",
b"%PDF": "application/pdf",
b"GIF87a": "image/gif",
b"GIF89a": "image/gif",
}
def detect(data: bytes) -> str | None:
for sig, mime in SIGNATURES.items():
if data.startswith(sig):
return mime
return None
Для форматов без короткой сигнатуры используют библиотеки определения типа, читающие структуру файла. Заголовок Content-Type из запроса проверять бесполезно — он тоже задаётся клиентом.
Четыре обязательных правила хранения и отдачи:
| Правило | Зачем |
|---|---|
| Хранить вне webroot | Файл нельзя выполнить как скрипт, даже если попал в каталог сайта |
| Генерировать имя на сервере | Исключает ../ и перезапись чужих файлов |
Отдавать с Content-Disposition: attachment |
Браузер скачивает, а не исполняет |
Отдавать с явным Content-Type |
Предотвращает определение типа браузером |
from fastapi.responses import FileResponse
@app.get("/files/{file_id}")
def download(file_id: str):
path = resolve_safe(file_id) # проверка пути без ../
return FileResponse(
path,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{safe_name}"',
"X-Content-Type-Options": "nosniff"},
)
Заголовок X-Content-Type-Options: nosniff запрещает браузеру угадывать тип содержимого. Без него текстовый файл с HTML внутри может быть исполнен как страница — о механизме в статье про XSS. Формат заголовка Content-Disposition определён в RFC 6266.
Практика: защита контура загрузки
1. Разрешающий список, а не запрещающий. Перечислите форматы, которые принимаете. Список запрещённых расширений всегда неполон.
2. Разделяйте приём и обработку. Файл сначала попадает в карантин, проверяется, и только затем становится доступным. Синхронная проверка в обработчике запроса увеличивает задержку, поэтому её выносят в очередь.
3. Антивирусная проверка для пользовательского контента. Если файлы читают другие пользователи, проверка обязательна. Типовое решение — отдельный сервис с доступом к бакету и событийным уведомлением о результате.
4. Ограничивайте размер и число файлов. Без лимита загрузка становится способом исчерпать место; механика описана в статье про загрузку файлов.
5. Не выполняйте ничего из загруженного. Загрузка изображений с последующей обработкой библиотекой безопаснее, чем запуск скриптов или открытие архивов с распаковкой.
6. Проверяйте архивы на zip bomb. Небольшой архив может распаковываться в гигабайты. Ограничивайте и объём распаковки, и глубину вложенности.
7. Изолируйте обработку. Разбор недоверенных форматов выполняют в процессе с ограниченными правами и ресурсами, поскольку уязвимости в парсерах изображений и архивов встречаются регулярно.
8. Логируйте факты загрузки. Имя, размер, тип, пользователь и результат проверки нужны для расследования; содержимое в лог не пишут.
Рекомендации:
- Проверяйте и на входе, и при отдаче. Файл мог попасть в систему до введения проверки.
- Используйте отдельный домен для пользовательского контента. Это исключает отправку cookie основного домена и упрощает политику.
- Не храните файлы в базе без причины. Двоичные данные в СУБД усложняют резервное копирование и увеличивают размер копии.
- Проверяйте права доступа к каждому файлу. Наличие идентификатора не означает права на чтение; типичная ошибка — доступ по последовательному номеру.
- Включите проверку в чек-лист ревью. Загрузка файлов относится к наиболее частым источникам уязвимостей — см. статью про OWASP Top 10.
Практический сценарий: загруженный SVG выполнял скрипт в браузере
Контекст: сервис позволял загружать аватары в форматах PNG, JPEG и SVG. Один из пользователей получил ссылку, при открытии которой в его сессии выполнялся сторонний скрипт.
- Диагностика. Загруженный файл имел расширение
.svgи корректную сигнатуру формата, поэтому проверка типа его пропускала. Внутри находился элементscript. - Причина. Файлы отдавались с заголовком
Content-Type: image/svg+xmlи безContent-Disposition. SVG является форматом на основе XML и поддерживает скрипты, поэтому браузер выполнял его как документ в контексте основного домена. - Усугубляющий фактор. Файлы хранились в том же домене, что и приложение, поэтому скрипт получал доступ к cookie сессии.
- Действия. (а) Исключили SVG из разрешённых форматов для пользовательских загрузок; (б) добавили
Content-Disposition: attachmentиX-Content-Type-Options: nosniffдля всех отдаваемых файлов; (в) перенесли пользовательский контент на отдельный поддомен без cookie основного домена; (г) ввели антивирусную проверку в очереди перед публикацией; (д) добавили проверку на наличие элементовscriptи внешних ссылок для форматов, основанных на XML. - Результат. Выполнение загруженного содержимого стало невозможным: файлы скачиваются, а не отображаются, и отделены от домена приложения.
Вывод: проверка типа по сигнатуре не защищает от содержимого внутри корректного формата. SVG, PDF и HTML остаются исполняемыми, поэтому важнее способ отдачи — принудительное скачивание и отдельный домен.
FAQ
Достаточно ли проверки расширения и MIME-типа? Нет. Оба значения задаёт клиент. Проверка нужна по содержимому файла, а разрешение должно задаваться списком допустимых форматов.
Почему нельзя хранить загрузки в каталоге сайта? Если файл окажется доступен по прямому пути, а сервер интерпретирует расширения, загруженный скрипт может быть выполнен. Хранение вне webroot исключает этот сценарий.
Нужна ли антивирусная проверка, если файлы доступны только загрузившему? Желательна. Файл может быть использован на другом устройстве пользователя, а проверка выявляет и вредоносные документы.
Как безопасно отдать файл с произвольным именем?
Использовать сгенерированный идентификатор в пути и передавать исходное имя только в Content-Disposition, экранируя кавычки и управляющие символы.
Источники: OWASP (Unrestricted File Upload), MDN и RFC 6266 (Content-Disposition) — ссылки в шапке.