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

Загрузки файлов кажутся безобидными: фото профиля, PDF, таблица. Но они часто становятся первой причиной инцидентов, потому что позволяют посторонним прислать вашей системе «тайную коробку». Если вы приняли её, сохранили и показываете другим, вы создали новый вектор атаки на приложение.
Риск — это не только «кто‑то загрузил вирус». Неправильная загрузка может слить приватные файлы, раздуть счёт за хранение или заставить пользователей отдать доступ. Файл с именем “invoice.pdf” может вовсе не быть PDF. Даже настоящие PDF и изображения могут создать проблемы, если приложение доверяет метаданным, автоматически генерирует превью или отдает их с неправильными правилами.
Типичные провалы выглядят так:
Одна деталь объясняет многие инциденты: хранение файлов — это не то же самое, что их выдача. Хранение — где вы держите байты. Выдача — как вы доставляете эти байты в браузеры и приложения. Проблемы возникают, когда приложение отдает пользовательские загрузки с тем же уровнем доверия и правилами, что и основной сайт, и браузер считает загрузку «доверенной».
«Достаточно безопасно» для малого или растущего приложения обычно означает, что вы можете честно ответить на четыре вопроса без уклончивости: кто может загружать, что вы принимаете, какой размер и как часто, и кто сможет читать файл позже. Даже если вы быстро собираете продукт (сгенерированный код или платформа на основе чата), эти ограждения важны.
Относитесь к каждой загрузке как к недоверенному входу. Практический способ защитить загрузки — представить, кто может злоупотребить ими и что для него будет «успехом».
Большинство злоумышленников — это либо боты, сканирующие слабые формы загрузки, либо реальные пользователи, пытающиеся получить бесплатное хранилище, собрать данные или потроллить сервис. Иногда это конкурент, ищущий утечки или сбои.
Чего они хотят? Обычно одно из следующих:
Затем сопоставьте слабые точки. Эндпоинт загрузки — это входная дверь (слишком большие файлы, странные форматы, высокая частота запросов). Хранилище — это задняя комната (публичные бакеты, неверные права, общие папки). URL для загрузки — это выход (предсказуемые, долгоживущие или не привязанные к пользователю).
Пример: функция «загрузить резюме». Бот загружает тысячи больших PDF, чтобы накрутить расходы, а злоумышленник заливает HTML-файл и выдаёт его за «документ», чтобы обмануть других.
Прежде чем вводить контролы, решите, что важно для вашего приложения: приватность (кто может читать), доступность (можете ли вы продолжать отдавать), стоимость (хранение и трафик) и соответствие требованиям (где хранится данные и как долго). Этот список приоритетов поможет принимать согласованные решения.
Большинство инцидентов с загрузками — это не сложные взломы. Это простые баги «я вижу чужой файл». Рассматривайте права как часть механики загрузок, а не как дополнительную фичу.
Начните с одного правила: по умолчанию отказ. Считайте каждый загруженный объект приватным, пока вы явно не разрешили доступ. «Приватно по умолчанию» — хорошая база для счетов, медицинских документов, документов аккаунта и всего, что привязано к пользователю. Делайте файлы публичными только когда пользователь этого явно ожидает (например, публичный аватар), и даже тогда подумайте о временном доступе.
Держите роли простыми и разделёнными. Частое разделение:
Не полагайтесь на правила уровня папки вроде «всё в /user-uploads/ ок». Проверяйте владение или доступ тенанта при чтении каждого файла. Это защитит, когда кто‑то меняет команду, уходит из организации или файл переназначают.
Хорошая модель для поддержки — узкая и временная: дать доступ к одному конкретному файлу, залогировать и автоматически истечь.
Большинство атак на загрузки начинаются с простой уловки: файл выглядит безопасно по имени или заголовку браузера, но на деле — не тот формат. Относитесь как к недоверенным ко всему, что присылает клиент.
Начните с allowlist: решите, какие точные форматы вы принимаете (например, .jpg, .png, .pdf) и отвергайте всё остальное. Избегайте «любое изображение» или «любой документ», если вам действительно не нужно такое широкое принятие.
Не доверяйте расширению имени файла или заголовку Content-Type от клиента. Их легко подделать. Файл с именем invoice.pdf может быть исполняемым, а Content-Type: image/png — ложью.
Надёжнее инспектировать первые байты файла, часто называемые «magic bytes» или сигнатурой файла. Многие форматы имеют стабильные заголовки (PNG, JPEG). Если заголовок не соответствует разрешённому формату — отклоняйте.
Практичная схема валидации:
Переименование важнее, чем кажется. Если вы храните имена, предоставленные пользователем, вы открываете путь к трюкам с путями, странными символами и перезаписями. Используйте сгенерированный ID для хранения и храните оригинальное имя только для отображения.
Для фото профиля принимайте только JPEG и PNG, проверяйте заголовки и очищайте метаданные если возможно. Для документов ограничьте PDF и отвергайте всё с активным содержимым. Если позже решите поддержать SVG или HTML — относитесь к ним как к потенциально выполняемым и изолируйте.
Большинство простоев при загрузках — это не «умные» хакерские приёмы. Это гигантские файлы, слишком много запросов или медленные соединения, которые загружают серверы до отказа. Считайте каждый байт затратой.
Выбирайте максимальный размер для каждой функции, а не одно глобальное число. Аватар не требует того же лимита, что налоговый документ или короткое видео. Ставьте минимально разумный лимит, затем добавляйте отдельный путь для больших файлов только когда он действительно нужен.
Применяйте ограничения в нескольких местах, потому что клиент может врать: в логике приложения, на веб‑сервере/реверс‑прокси, с таймаутами загрузки и с ранним отклонением, когда заявленный размер слишком велик (до чтения всего тела).
Конкретный пример: аватары — до 2 MB, PDF — до 20 MB, всё большее — через другой поток (например, прямой upload в объектное хранилище с подписанным URL).
Даже маленькие файлы могут привести к DoS, если их загружают бесконечно. Добавьте rate limit для эндпоинтов загрузки по пользователю и по IP. Для анонимных запросов сделайте ограничения строже.
Возобновляемые (resumable) загрузки помогают реальным пользователям с плохой связью, но session‑токен должен быть жёстким: короткий срок жизни, привязан к пользователю и к конкретному размеру/назначению файла. Иначе endpoint «resume» станет бесплатной трубой в ваше хранилище.
Когда вы блокируете загрузку, возвращайте понятные пользователю ошибки (файл слишком большой, слишком много запросов), но не раскрывайте внутренности (стектрейсы, имена бакетов, детали поставщика).
Безопасные загрузки — это не только то, что вы принимаете. Это также где файл хранится и как вы его отдаёте.
Не храните байты файлов в основной базе данных. Большинству приложений нужна только метаинформация в БД (ID владельца, оригинальное имя, определённый тип, размер, контрольная сумма, ключ хранилища, время создания). Байты держите в объектном хранилище или сервисе, предназначенном для больших BLOB.
Разделяйте публичные и приватные файлы на уровне хранения. Используйте разные бакеты/контейнеры с разными правилами. Публичные файлы (публичные аватары) могут читаться без логина. Приватные файлы (контракты, счета, медицинские документы) никогда не должны быть общедоступными, даже если кто‑то угадает URL.
По возможности не отдавайте пользовательские файлы с того же домена, что и основное приложение. Если рискованный файл просочится (HTML, SVG со скриптами или странности MIME sniffing), хостинг на основном домене может привести к захвату аккаунтов. Отдельный домен загрузок или домен хранилища ограничит радиус поражения.
При скачивании заставляйте безопасные заголовки. Ставьте предсказуемый Content-Type на основе разрешённых типов, а не того, что утверждает пользователь. Для всего, что браузер может интерпретировать, предпочитайте отдавать как скачивание.
Несколько дефолтов, предотвращающих сюрпризы:
Content-Disposition: attachment для документов.Content-Type (или application/octet-stream).Хранение — это также безопасность. Удаляйте брошенные загрузки, удаляйте старые версии после замены и ставьте срок жизни для временных файлов. Менее данных — меньше риска утечки.
Подписанные URL (pre-signed) — распространённый способ позволить пользователям загружать или скачивать файлы, не делая бакет публичным и не пропуская каждый байт через ваш API. В URL заложено временное разрешение, затем оно истекает.
Два распространённых потока:
Прямая загрузка снижает нагрузку на API, но делает правила хранилища и ограничения в URL еще более критичными.
Относитесь к подписанному URL как к одноразовому ключу. Делайте его специфичным и с коротким сроком жизни.
Практичный паттерн: сначала создайте запись загрузки (status: pending), затем выдайте подписанный URL. После загрузки проверьте, что объект существует и соответствует ожидаемому размеру и типу, прежде чем пометить его как готовый.
Безопасный поток — это в основном ясные правила и понятные состояния. Относитесь к каждой загрузке как к недоверенной, пока проверки не пройдут.
Опишите, что каждая фича позволяет. Фото профиля и налоговый документ не должны использовать одинаковые типы файлов, лимиты размера или видимость.
Определите допустимые типы и лимит размера по фиче (например: фото до 5 MB; PDF до 20 MB). Применяйте те же правила на бэкенде.
Создайте «запись загрузки» до прихода байтов. Храните: владелец (пользователь или организация), назначение (avatar, invoice, attachment), оригинальное имя файла, ожидаемый макс. размер и статус вроде pending.
Загружайте в приватное местоположение. Не позволяйте клиенту выбирать конечный путь.
Снова проверьте на сервере: размер, magic bytes/тип, allowlist. Если прошло — переведите статус в uploaded.
Просканируйте на вредоносное ПО и обновите статус на clean или quarantined. Если сканирование асинхронное, держите доступ закрытым пока ждёте.
Разрешайте скачивание, превью или обработку только при статусе clean.
Небольшой пример: для фото профиля создайте запись, привязанную к пользователю с целью avatar, храните приватно, подтвердите, что это реально JPEG/PNG (а не просто переименованный файл), просканируйте, затем сгенерируйте превью URL.
Сканирование — это страховочная сетка, а не гарантия. Оно ловит известные плохие файлы и очевидные трюки, но не всё. Цель простая: снизить риск и по умолчанию сделать неизвестные файлы безвредными.
Надёжный паттерн — сначала карантин. Сохраняйте каждую новую загрузку в приватном месте и помечайте как pending. Только после прохождения проверок перемещайте в «clean» (или помечайте как доступный).
Синхронные сканирования работают только для маленьких файлов и низкого трафика, потому что пользователь ждёт. Большинство приложений сканируют асинхронно: принимают загрузку, возвращают состояние «обработка», сканируют в фоне.
Базовое сканирование обычно — это антивирусный движок (или сервис) плюс несколько правил: AV‑скан, проверки типа файла (magic bytes), лимиты для архивов (zip bomb, вложенные zip, огромный распакованный размер) и блокировка форматов, которые вам не нужны.
Если сканер упал, таймаутнулся или вернул «неизвестно», считайте файл подозрительным. Держите его в карантине и не давайте ссылку для скачивания. Именно здесь команды часто обжигаются: «скан не прошёл» не должно превращаться в «всё равно публикуем».
При блокировке файла формулируйте нейтральное сообщение: «Мы не смогли принять этот файл. Попробуйте другой файл или свяжитесь с поддержкой.» Не утверждайте, что нашли вредоносное ПО, если вы не уверены.
Начните с принципа приватно по умолчанию и относитесь к каждой загрузке как к недоверенному входному значению. Выполните четыре базовых проверки на сервере:
Если вы чётко ответите на эти вопросы, вы уже опережаете большинство инцидентов.
Потому что пользователь может загрузить «тайную коробку», которую ваше приложение сохранит и затем может показать другим. Это ведёт к таким проблемам:
Редко дело сводится только к «кто‑то загрузил вирус».
Хранение — это место, где вы держите байты. Отдача (serving) — это как вы передаёте эти байты браузерам и приложениям.
Опасность возникает, когда приложение отдаёт загруженные пользователями файлы с тем же уровнем доверия и правилами, что и основной сайт. В таком случае браузер может выполнить или интерпретировать файл как доверенную страницу.
Безопаснее: хранить приватно, а отдавать через контролируемые ответы с безопасными заголовками.
Используйте принцип по умолчанию отказано и проверяйте доступ при каждом скачивании или просмотре.
Практические правила:
Большинство реальных багов — это простые «я вижу чужой файл» ошибки.
Не доверяйте расширению файла или заголовку Content-Type от клиента. Валидируйте на сервере:
Отказы и простои чаще всего вызваны банальными злоупотреблениями: слишком много загрузок, гигантские файлы или медленные соединения, которые занимают ресурсы.
Рекомендации:
Относитесь к каждому байту как к затратам и к каждому запросу как к потенциальной атаке.
Да, но осторожно. Подписанные URL дают возможность браузеру загружать/скачивать прямо в хранилище, не делая бакет публичным.
Хорошие практики:
Прямая загрузка в хранилище снижает нагрузку на API, но делает критичными области ограничения и сроки жизни URL.
Самый безопасный шаблон:
pendingСканирование полезно, но не даёт абсолютной гарантии. Используйте его как дополнительную защиту.
Практический подход:
Политика должна быть строгой: «не просканирован» ≠ «доступен».
Отдавайте файлы так, чтобы браузер не интерпретировал их как страницу.
Полезные дефолты:
Content-Disposition: attachmentContent-Type, выбранный сервером (или application/octet-stream)Если байты не соответствуют разрешённому формату — отклоняйте загрузку.
cleanquarantinedcleanЭто предотвращает случайное распространение файлов, которые ещё не проверены.
Это снижает риск, что загруженный файл превратится в фишинговую страницу или выполнит скрипт.