Чек-лист тестирования загрузки файлов: типы, размеры, безопасность и UX
Поле загрузки файла выглядит безобидно — кнопка «Выбрать файл» и всё. Но это единственное место, где пользователь кладёт произвольные байты на твой сервер. Здесь сходятся сразу три класса проблем: функциональные (не тот тип, битый файл), безопасностные (загрузили вебшелл) и UX (загрузка на 40 МБ без прогресса и отмены). Поэтому аплоад — стабильный источник и багов, и настоящих инцидентов, и почти всегда недотестирован.
Ниже — чек-лист по разделам: от типов и размеров до хранения. Не «прокликать happy path», а именно попытаться сломать.
Типы и MIME — не верь расширению
Главный принцип: расширение и заголовок Content-Type контролирует клиент, значит им нельзя доверять.
- Расширение vs реальное содержимое. Переименуй
shell.phpвphoto.jpg— примет ли сервер? А наоборот, валидный PNG с расширением.txt? Настоящий тип определяется по magic bytes (сигнатуре), а не по имени. - Двойное расширение.
invoice.pdf.exe,avatar.jpg.svg— какое расширение возьмёт валидация, первое или последнее? А как файл отдастся обратно? - Подмена
Content-Type. Через прокси (Burp) поставьContent-Type: image/pngна исполняемый файл. Валидация на MIME из запроса — дырка. - Пустой и странный MIME.
application/octet-stream, отсутствующий Content-Type, экзотические типы. - Полиглот-файлы. Файл, валидный сразу как GIF и как JS/PHP (GIFAR и родственники). Проходит проверку «это картинка» — и исполняется как скрипт.
- Регистр и пробелы.
.JPG,.Jpg,photo.jpg(пробел в конце), unicode-омоглифы в расширении. - Whitelist, а не blacklist. Проверь, что разрешён явный список допустимых типов, а не «запретим .exe и .php». Блэклист всегда неполный.
Размеры и количество
- 0 байт. Пустой файл — падение, 500 или корректная ошибка?
- 1 байт / обрезанный. Валидный заголовок, но файл оборван — как ведёт себя обработчик/превью.
- Гигантский файл. Больше лимита, ровно на границе лимита, на 1 байт больше. Лимит проверяется на клиенте и на сервере (клиентский легко обойти через API).
- Zip-bomb / декомпрессия. Если принимаешь архивы или что-то распаковываешь (docx, картинки с огромным разрешением) — маленький файл, который разворачивается в гигабайты. «Пиксель-бомба»: 64000×64000 PNG весит килобайты, а в память при ресайзе просит терабайты.
- Множественная загрузка. Много файлов разом, лимит на количество, суммарный размер батча, один битый в середине пачки.
- Долгая/медленная загрузка. Slowloris-подобная — очень медленная отправка большого тела: держит ли сервер соединение вечно, есть ли таймаут.
Имена файлов
Имя файла — это тоже пользовательский ввод, причём его часто кладут в путь на диске или в заголовок ответа.
- Path traversal.
../../etc/passwd,..\..\web.config— можно ли выйти из папки загрузок и перезаписать чужой файл. Классика RCE. - Спецсимволы и управляющие.
;,|,&,$(), перевод строки, NULL-байт%00(обрезание имени), кавычки. - Очень длинное имя. 255+ символов, 4096 символов — лимиты ФС и БД.
- Unicode и эмодзи. Кириллица, арабица (RTL), эмодзи, RTL-override
U+202E(маскируетexeкакgpj). - Зарезервированные имена. На Windows:
CON,PRN,NUL,COM1,AUX. - Дубли. Два файла с одинаковым именем — перезапись или уникализация? Гонка при одновременной загрузке одинакового имени.
- Заголовок ответа. Если имя возвращается в
Content-Disposition— проверь инъекцию заголовка и корректный экранинг.
Безопасность — здесь ставки выше всего
- Куда сохраняется и как отдаётся. Если файлы лежат в веб-доступной папке и отдаются по прямой ссылке — залитый
.php/.jspможет исполниться. Правильно: хранить вне webroot или в объектном сторадже, отдавать через контроллер с правильнымContent-TypeиContent-Disposition: attachment. - SVG = исполняемый. SVG — это XML, в него встраивается
<script>и обработчики событий. Открытый инлайном в браузере жертвы — это stored XSS. Либо запрещай SVG, либо жёстко санитизируй (например, через DOMPurify), либо отдавай только как attachment. - EXIF и приватность. Фото с телефона несёт GPS-координаты, модель, время. Публикуешь аватар — не публикуй геолокацию автора. Проверь, что метаданные стрипаются.
- Антивирус / скан. Загружаемое сканируется (ClamAV/облачный скан)? Тест — файл EICAR (безопасная антивирусная тест-сигнатура): ловится ли.
- Авторизация. Может ли пользователь A прочитать/перезаписать файл пользователя B, подставив чужой ID или угадав имя? Прямая ссылка на файл требует проверки прав, а не «секретности» URL.
- IDOR на скачивание. Перебор
?file=1001,1002— отдаются ли чужие документы. - XXE через офисные форматы. docx/xlsx — это zip с XML; парсер с включёнными внешними сущностями = XXE.
- Стоимость и абьюз. Аплоад без rate limit = вектор для заполнения диска и денег на трафик/обработку.
Обработка, превью и рендер
- Битый/обрезанный файл при генерации превью — graceful error, а не 500 и не падение воркера.
- Ориентация по EXIF. Фото «лежит на боку» — учитывает ли ресайзер
Orientation. - Ресайз и качество. Прозрачность PNG, анимация GIF/WebP, цветовые профили, слишком большое разрешение (см. пиксель-бомбу выше).
- Асинхронная обработка. Если конвертация в фоне — что показывается пользователю, пока не готово; что если воркер упал; повторная попытка.
- Идемпотентность. Повторная загрузка того же файла (дабл-тап, ретрай сети) не должна плодить дубли или списывать что-то дважды.
UX и надёжность
- Прогресс. Есть ли индикатор для больших файлов; корректен ли процент.
- Отмена и докачка. Можно ли отменить; продолжается ли загрузка после обрыва сети (resumable).
- Обрыв сети посередине. На мобильном — Wi-Fi→cellular, лифт, метро. Частичный файл на сервере не должен считаться валидным.
- Drag & drop и вставка из буфера (скриншот Ctrl+V), выбор из камеры/галереи на мобильном.
- Понятные ошибки. «Слишком большой файл (макс 10 МБ)» вместо «Ошибка 413». Ошибка про запрещённый тип называет разрешённые.
- Оффлайн и повторы. Что происходит без сети; не отправляется ли форма дважды.
- Доступность. Кнопка достижима с клавиатуры и озвучивается скринридером, состояние загрузки анонсируется.
Хранение и жизненный цикл
- Где лежит: объектный сторадж (S3 и аналоги) vs локальный диск; приватный бакет vs публичный.
- Осиротевшие файлы. Загрузил, но форму не отправил — файл остаётся навсегда? Есть ли уборка временных.
- Удаление. При удалении сущности удаляется ли сам файл; удаляются ли кэш/CDN-копии и превью.
- Лимиты и квоты на пользователя; поведение при исчерпании.
Сводный чек-лист
- Тип определяется по magic bytes, не по расширению и не по
Content-Typeиз запроса; whitelist разрешённых типов - Двойные расширения, полиглоты, подмена MIME через прокси — отбиты
- Границы размера: 0 байт, обрезанный, ровно лимит, лимит+1; проверка на сервере, не только на клиенте
- Zip-/пиксель-бомба не роняет обработку
- Имя файла санитизируется: path traversal, спецсимволы, NULL, RTL-override, длина, дубли
- Файлы вне webroot или в сторадже; отдаются контроллером с
attachment, не исполняются - SVG запрещён/санитизирован; EXIF-метаданные стрипаются; вирус-скан (EICAR) работает
- Авторизация на чтение/удаление; нет IDOR при скачивании
- Прогресс, отмена, обрыв сети, drag&drop, понятные ошибки, доступность
- Осиротевшие файлы убираются; удаление сущности чистит файл, превью и CDN
Инструменты
- Burp Suite / OWASP ZAP — подмена
Content-Type, имени, повтор запроса в обход клиентской валидации. file(по magic bytes) и exiftool — проверить, как система определяет тип и что с метаданными.- EICAR тест-файл — проверить, что антивирус-скан вообще работает.
- Готовые «злые» картинки/полиглоты и генераторы декомпрессионных бомб — для проверки обработки.
Аплоад стоит воспринимать не как «прикрепить картинку», а как приём недоверенных данных на свою инфраструктуру. Тестируй с этой позиции — и половина находок появится сама.