Чек-лист тестирования загрузки файлов: 30+ кейсов, которые забывают
Поле загрузки файла выглядит безобидно: выбрал картинку — нажал «Загрузить». Но за этой кнопкой прячется едва ли не больше граничных случаев, чем за любой формой: содержимое vs расширение, размеры, имена с ../, обрыв сети на середине, и целый пласт уязвимостей, через которые загрузка превращается в выполнение кода на сервере.
Хорошая новость — это тестируется по чек-листу, и он переиспользуется везде: аватар, документ, фото в объявление, импорт CSV, вложение в тикет. Ниже — разбор по группам и плоский чек-лист в конце.
1. Содержимое важнее расширения
Главная ошибка — доверять расширению и заголовку Content-Type. И то, и другое контролирует клиент, а значит — злоумышленник. Файл shell.php, переименованный в photo.jpg, спокойно пройдёт проверку «по точке».
- Сервер определяет тип по содержимому (magic bytes / сигнатуре), а не по расширению и не по
Content-Typeиз запроса. - Реальная картинка с расширением
.txtи текст с расширением.png— обрабатываются корректно (приняты/отклонены осознанно, без краша). - Whitelist разрешённых типов, а не blacklist запрещённых (чёрный список всегда дырявый).
- Polyglot-файл (валидный и как GIF, и как HTML/JS одновременно) — не проходит как «безобидная картинка».
- Файл без расширения, с двойным расширением (
invoice.pdf.exe), с заглавным (.JPG,.PnG) — обрабатываются предсказуемо.
2. Размеры: ноль, лимит, лимит+1, гигант
- Пустой файл (0 байт) — отклоняется с понятной ошибкой, а не «успешно загружен».
- Ровно на лимите, лимит−1, лимит+1 байт — границы работают как заявлено.
- Очень большой файл (несколько ГБ) — не кладёт сервер, не съедает память (стриминг, а не загрузка целиком в RAM); лимит проверяется и на клиенте, и на сервере, и на уровне веб-сервера (nginx
client_max_body_size). - Decompression bomb: маленький архив/картинка, которая разворачивается в гигабайты. Классическая 42 КБ → 4.5 ПБ zip-бомба; для изображений — «pixel flood» (картинка 100000×100000). Размер после распаковки/декодирования тоже под лимитом.
3. Имена файлов — отдельный источник боли
Имя файла приходит от пользователя, и его нельзя использовать «как есть» — ни для сохранения на диск, ни для отдачи обратно.
- Path traversal:
../../etc/passwd,..\..\windows— не вырывается из директории загрузок. Лучшая защита — генерировать своё имя (UUID), а оригинал хранить отдельно как метаданные. - Очень длинное имя (255+ символов), имя из одних пробелов/точек (
....), зарезервированные в Windows (CON,NUL,PRN). - Unicode и emoji в имени (
счёт_2026_😀.pdf), RTL-override (U+202E) — маскирует расширение:photogpj.exeвыглядит наоборот; отображается и сохраняется без краша и без обмана. - Спецсимволы и инъекции в имени: кавычки,
<script>,;,$()— экранируются и в UI (имя файла в списке), и в заголовкеContent-Dispositionпри скачивании. - Дубликат имени: два
photo.jpgне перезаписывают друг друга молча и не отдают чужой файл.
4. Сам процесс загрузки
- Способы выбора: кнопка, drag & drop, вставка из буфера (Ctrl+V скриншота), камера на мобиле — все ведут к одному результату.
- Множественная загрузка: выбор N файлов; один битый в пачке не валит остальные; счётчик/прогресс по каждому.
- Прогресс, отмена и пауза работают; отмена на середине не оставляет «недофайл» на сервере и освобождает место.
- Обрыв сети в процессе: понятная ошибка, повтор без дублей (resumable upload / идемпотентный ключ), уже выбранный файл не теряется.
- Медленная сеть (2G/3G), большой файл по мобильному интернету: не подвисает UI, есть таймаут с retry.
- Мобайл: сворачивание/звонок/блокировка экрана во время загрузки — продолжается или корректно возобновляется, а не молча обрывается.
- Двойной клик по «Загрузить» = один запрос (идемпотентность), кнопка дизейблится на время отправки.
5. Обработка на сервере и хранилище
- Картинки: ресайз/перекодирование, генерация превью; повёрнутое фото с EXIF-orientation отображается правильно; метаданные (GPS-координаты!) вырезаются для приватности.
- Где лежит файл: вне webroot (нельзя обратиться напрямую по URL и выполнить); отдача через контроллер с проверкой прав.
- Приватность и доступ: чужой файл нельзя получить, подставив id/имя (IDOR); приватные файлы — через подписанные ссылки с истечением, а не «по угадываемому пути».
- CDN/кэш не кэширует приватный файл публично; после удаления файл реально недоступен (и в кэше тоже).
- Квоты пользователя: достижение лимита хранилища обрабатывается, а не падает 500.
6. Безопасность загрузки — главное
Здесь живут самые дорогие баги. Базовые ориентиры — OWASP File Upload Cheat Sheet и Unrestricted File Upload.
- Валидация по содержимому + whitelist расширений + лимит размера — три проверки вместе, не вместо друг друга.
- SVG со встроенным
<script>→ XSS при просмотре; SVG либо запрещён, либо санитайзится, либо отдаётся какattachment, а не рендерится inline. - Запрет исполнения в папке загрузок: нет интерпретации
.php/.jsp; хранение вне webroot; отдельный домен/поддомен для пользовательского контента. - Ответ на скачивание:
Content-Typeкорректный,X-Content-Type-Options: nosniff(чтобы браузер не «угадал» HTML в картинке),Content-Disposition: attachmentдля не-просматриваемых типов. - RCE через обработку: уязвимые библиотеки (ImageMagick «ImageTragick», Ghostscript, ffmpeg) — обновлены; обработка в песочнице/с ограничениями.
- SSRF, если можно «загрузить по URL»: сервер не ходит на
169.254.169.254/внутренние адреса; схемыfile://,gopher://запрещены. - Антивирус-скан (например, ClamAV) для документов/вложений; EICAR-тест-файл реально отлавливается.
- Null-byte и двойное расширение (
file.php%00.jpg,file.php.jpg) не обходят проверку.
7. UX-состояния и доступность
- Понятная ошибка: почему файл отклонён (тип? размер? имя?), а не «Ошибка».
- Все состояния видны: пусто → выбран → загрузка(%) → успех → ошибка → повтор.
- Кастомная dropzone доступна с клавиатуры и для скринридера (есть нативный
<input type=file>под капотом,aria-label, анонс прогресса черезaria-live). - Превью не ломает вёрстку (вертикальное/панорамное фото, прозрачный PNG, битый файл → плейсхолдер).
- Подсказка о допустимых форматах и лимите — до попытки, а не только в тексте ошибки.
Плоский чек-лист (сохрани себе)
- Тип определяется по содержимому (magic bytes), не по расширению/Content-Type
- Whitelist типов и расширений, лимит размера — на клиенте и на сервере
- 0 байт, лимит, лимит±1, гигантский файл, decompression/zip/pixel bomb
- Path traversal в имени (
../), своё имя (UUID), оригинал — в метаданные - Длинное имя 255+, точки/пробелы, зарезервированные имена, RTL-override
- Unicode/emoji в имени; спецсимволы экранируются в UI и Content-Disposition
- Дубликаты имён не перезаписывают и не отдают чужой файл
- Кнопка / drag&drop / paste / камера — единый результат
- Multiple: битый в пачке не валит остальные, прогресс по каждому
- Прогресс/отмена/пауза; отмена не оставляет мусор на сервере
- Обрыв сети → понятная ошибка, повтор без дублей, ввод не потерян
- Медленная сеть/мобайл, сворачивание во время загрузки
- Двойной клик = один запрос; кнопка дизейблится
- EXIF: ориентация учтена, GPS/метаданные вырезаны
- Файлы вне webroot, отдача через контроллер с проверкой прав
- Нет IDOR: чужой файл не получить по id/имени; приватные — signed URLs
- SVG не рендерится inline (санитайз/attachment) — нет XSS
- Нет исполнения
.php/.jspв папке загрузок nosniff+ корректный Content-Type + Content-Disposition: attachment- Обработчики (ImageMagick/Ghostscript/ffmpeg) обновлены, в песочнице
- Upload-by-URL не делает SSRF (внутренние адреса/метадата/
file://) - Антивирус-скан; EICAR ловится
- Null-byte и двойное расширение не обходят проверку
- Квота/лимит хранилища обрабатывается без 500
- Понятные ошибки и все состояния (пусто/загрузка/успех/ошибка)
- Dropzone доступна с клавиатуры и скринридера; превью не ломает вёрстку
Итог. Загрузка файла — это мини-форма с собственной физикой: контент важнее расширения, имя нельзя доверять, сеть рвётся, а валидация «по точке» открывает RCE. Прогоните этот чек-лист на любом поле upload — и закроете большинство «скучных» и пару очень дорогих багов сразу.
Источники: OWASP File Upload Cheat Sheet, OWASP Unrestricted File Upload, David Fifield — A better zip bomb.