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