checklistfile-uploadsecuritytesting

Чек-лист тестирования загрузки файлов: 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) — маскирует расширение: photo‮gpj.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.