Skip to content

Latest commit

 

History

History
89 lines (50 loc) · 18.8 KB

files-upload.md

File metadata and controls

89 lines (50 loc) · 18.8 KB

Как безопасно загружать файлы пользователей на сервер

Допустим, у нас есть форум, и мы хотим разрешить на нем загрузку файлов: аватарок, а также приложений к постам. Если мы не будем тщательно проверять получаемые данные, то наш форум будет легко взломать: злоумышленник загрузит файл с именем script.php к нам на сервер и, введя URL вроде https://example.com/uploads/script.php в строку браузера, сможет запустить этот скрипт.

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

Загрузка файлов

Обычно для загрузки файла мы делаем HTML-форму с полем <input type="file">. При ее отправке браузер посылает POST-запрос, в теле которого передается: имя файла, MIME-тип и содержимое файла. MIME-тип - это строчка вида image/png, где первая часть описывает общий тип данных (картинка), а вторая - уточняет его. Краткий список основных MIME-типов можно найти в Википедии, а полный реестр ведет IANA. Обычно браузер определяет MIME-тип по расширению, не проверяя содержимое файла.

Если мы используем PHP, то он автоматически извлекает из тела запроса свойства файла и помещает их в массив $_FILES в поля name и type. Содержимое файла сохраняется во временный файл, путь к нему помещается в поле tmp_name, его размер в size, и статус загрузки (успех/ошибка) помещается в error.

Каким из полученных данных мы можем доверять? Только тем, что заполняет PHP. Поля name и type приходят извне, и в них может быть что угодно, как и в содержимом файла, потому мы должны их проверить, перед тем как принять файл.

Поле type сразу стоит игнорировать. Доверять ему мы не можем, и никакой полезной информации оно не несет.

Имена файлов

Поле name содержит имя файла с расширением. Оно может быть любым (в том числе пустым) и содержать любые символы. Расширение очень важно, так как именно на основании расширения веб-сервер решает, что делать с файлом - отдать пользователю или выполнить как PHP-код. Поэтому мы должны сформировать «белый» список расширений и принимать только файлы, чьи расширения в нем присутствуют. Например, мы можем разрешить только файлы с расширениями png, jpeg, jpg и gif. При этом стоит перевести расширение в нижний регистр, так как в системах на Windows файл вполне может называться и IMAGE.PNG, и пользователь не поймет, почему его файл не загружается. Определить расширение можно функцией pathinfo().

Неправильно пытаться сделать «черный» список запрещенных расширений и разрешать все, что не в списке, так как вполне может оказаться, что какой-то опасный формат файлов будет разрешен. Например, в системах на Windows файлы с малоизвестным расширением scr исполняемые, и злоумышленник сможет загружать к нам вредоносные программы (они не будут выполняться на сервере, но кто-нибудь может их скачать и запустить у себя). Файлы .htaccess меняют настройки веб-сервера Apache, а некоторые веб-серверы запускают файлы с расширением .phtml как PHP-код.

Нежелательно также сохранять неизменным исходное имя файла до расширения. Во-первых, оно может совпадать с именем ранее загруженного другим пользователем файла и при загрузке исходный файл будет перезаписан. Во-вторых, оно может содержать какие-то символы, запрещенные в файловой системе, например в Linux имя файла не может содержать символ с кодом 0 (\0) и слеш /, а в Windows - символы вроде <, | или ". Или оно может содержать какие-нибудь невидимые символы (из-за чего администратору трудно будет просматривать список файлов).

Также, в Юникоде есть символы, которые заставляют текст выводиться справа налево (например: RLM - Right-to-Left Mark). Добавив такой символ в имя файла, мы можем сделать файл, имя которого будет выводиться на экране как php.lesson.png, хотя фактическое имя будет (спецсимвол RLM)png.lesson.php.

Поэтому стоит придумать схему, по которой будут переименовываться файлы. Например, для аватарок можно использовать путь вроде /avatar/123/1234567.png. Мы строим путь на основе id пользователя, и используем подпапку 123, чтобы у нас не было миллиона файлов в одной большой папке. Если мы загружаем картинки для статьи, то для продвижения в поисковых системах можно добавлять в название описание картинки или (что хуже) название статьи. Чтобы у двух картинок не получилось одинаковое имя, мы добавим в путь id статьи и номер картинки в статье. Мы можем использовать транслит (латиницу), или русский язык, если сервер настроен правильно. Пример: /articles/123/схема-измерителя-напряжения-1.svg.

Если же речь идет о вложениях к постам на форуме, то тут хочется сохранить исходное имя файла, так как оно может нести полезную информацию. Потому можно определить список разрешенных символов (например: [a-zA-Zа-яёА-Яё0-9_\-]), минимальную и максимальную длину и привести имя в соответствие правилам. Чтобы имена были уникальные, добавим в путь id поста и номер вложения: /forum/attachments/1234567/1-my-program.txt.

Можно увидеть совет просто использовать порядковый номер или генерировать имя из хеша вроде MD5. Недостаток такого подхода в том, что получаются «некрасивые» URL, которые меньше нравятся поисковикам. Если администратор просматривает папку с загруженными файлами, то ему трудно будет в ней ориентироваться. Также, когда пользователь скачивает файл, он хочет получить файл с понятным именем, а не случайным набором символов.

Стоит учесть, что имя файла может раскрывать информацию. Например, если на сайте мы разрешаем оставлять сообщения с картинками анонимно, но путь к картинке содержит id загрузившего её пользователя, то анонимность теряется.

Содержимое файла

При загрузке файлов стоит ограничить размер загружаемого файла и их количество, иначе злоумышленник легко сможет заполнить весь диск сервера. В Nginx размер ограничивается директивой client_max_body_size, в Apache — LimitRequestBody. Также, ограничения можно выставить в настройках PHP директивами upload_max_filesize и post_max_size.

Мы также можем попытаться проверить тип файла по содержимому. Многие форматы файлов (например: png, jpeg) имеют так называемые «магические байты» в теле файла, по которым их можно распознать. Функция mime_content_type() использует именно такой подход. Можно проверить, что MIME-тип содержимого соответствует расширению.

Однако, нельзя полагаться только на эту функцию. Например, файл картинки может содержать в себе текстовый комментарий и, если мы поместим в комментарий текст <?php ... ?>, то получим файл, который является одновременно и картинкой, и PHP-скриптом. Как именно интерпретировать файл, веб-сервер обычно определяет по расширению, потому в первую очередь проверять надо именно расширение.

Также некоторые типы файлов не содержат «магических» байтов. Например, их нет в текстовых файлах и исходных кодах программ. Соответственно, их тип определить не получится.

Дополнительные меры безопасности

Хорошей идеей будет отключить в папке для загружаемых файлов выполнение PHP кода. Для Apache это можно сделать, добавив в папку файл .htaccess с директивой php_flag engine off (мануал). Также эту настройку можно прописать в конфигурации веб-сервера (httpd.conf), что еще надежнее. Для Nginx этого можно добиться, написав правильное регулярное выражение в блоке location, который отвечает за запуск PHP-скриптов:

location ~ ^/[a-zA-Z0-9_\-]+\.php$ {
	fastcgi_pass 127.0.0.1:9000;
	....
}

Такая конфигурация позволяет выполнять PHP-скрипты только из корневой папки. За это отвечает символ ^/ в начале регулярного выражения.

Если вы используете веб-фреймворк, в котором все запросы обрабатывает один скрипт (например, index.php), то вы можете настроить Nginx или Apache так, чтобы они вызывали только этот скрипт, независимо от того, что указано в URL.

Для загрузки пользовательских файлов можно выделить отдельный поддомен (files.example.com) или даже отдельный домен (example-files.com), на котором разместить Nginx, который только раздает статические файлы и на котором нету возможности выполнения PHP-кода.

HTML и SVG файлы

Стоит быть осторожным, если мы разрешаем загружать HTML и SVG файлы. Эти файлы могут содержать JavaScript-код (SVG может включать в себя HTML), который может в том числе обращаться к кукам. Злоумышленник может загрузить на наш сайт HTML-страницу (получив URL вроде https://example.com/uploads/page.html ) и, заманив на нее другого пользователя, похитить его куки. Конечно, если наш веб-сервер настроен правильно, то он отдаст HTML-страницу с заголовком Content-Disposition: attachment, который заставляет браузер не отобразить ее, а показать диалог сохранения файла. Но что, если мы забыли про эту настройку, или если найдется какой-то способ заставить браузер отобразить HTML-файл?

Основной способ защиты здесь — использовать отдельный домен, вроде files-example.com. На таком домене недоступны куки сайта example.com. Гугл, к примеру, использует домен googleusercontent.com для файлов, которые загружают пользователи.

Дополнительно можно настроить веб-сервер, чтобы для файлов из папки загрузок он отдавал заголовок Content-Security-Policy:

Content-Security-Policy: default-src 'none'; script-src 'none'; form-action 'none'; 

default-src запрещает HTML-странице загружать любые ресурсы, script-src — исполнять JavaScript, form-action — отправлять формы (чтобы злоумышленник не мог уговорить пользователя ввести какие-либо данные в форму и отправить её). В будущем, когда браузеры начнут поддерживать это, можно будет добавить еще директиву navigate-to 'none';, которая запрещает переходить по ссылкам со страницы. Эти настройки помогут, если злоумышленник сможет добиться отображения HTML-файла в браузере вместо загрузки его как файла.

Загрузка PHP-файлов

Что, если мы хотим разрешить загружать произвольные файлы, включая PHP-файлы? Например, у нас форум, посвященный программированию и мы хотим, чтобы пользователи могли обмениваться программами. Можно поступить так: сохранять загружаемые файлы в недоступную снаружи папку uploads с именами вроде 1234567.bin, а в базу данных сохранять информацию о файле. Затем мы генерируем человеко-понятную ссылку вроде /download/12345/program.php (в реальности файл хранится в другой папке и с другим именем), и настраиваем сервер, чтобы при обращении по пути /download/ он бы запускал PHP-скрипт download.php. Этот скрипт находит в БД настоящее имя файла и отдает его, дополнив заголовками Content-Disposition: attachment и Content-Security-Policy.

Итог

Для безопасной загрузки файлов необходимо как минимум:

  • настроить ограничения на размер загружаемого файла
  • проверять расширение файла по «белому» списку разрешенных расширений
  • переименовывать загружаемые файлы
  • отключить выполнение PHP-кода в папке для загрузок
  • настроить сервер, чтобы он отдавал заголовки Content-Disposition: attachment и Content-Security-Policy для файлов из папки для загрузок