Тестирование веб-ориентированных приложений. Часть-4: безопасность в PHP.

Тестирование веб-ориентированнх приложений. Часть-4: безопасность в PHP.Эта заметка больше ориентирована на разработчиков, чем на тестировщиков, однако никто не отменял чудесную мысль о том, что "чтобы что-то проверить, нужно знать, что там должно быть, и как оно работает". И, конечно же, тестирование по методу белого ящика никто не отменят.

Итак: PHP, безопасность, её обеспечение и тестирование.

Всё контролируем сами, вручную, не полагаясь на случай

Законы Мерфи гласят, что если что-то может навернуться, — оно навернётся. Это справедливо для любой разработки и любого тестирования вообще, не говоря уже о вопросах безопасности. Как только какая-то операция выполняется в надежде на то, что "что-то будет так-то и так-то" — это потенциальная проблема, потенциальная дыра в безопасности. Поэтому — проверяем всё, что только можно. Даже излишние на первый взгляд проверки иногда по факту оказываются очень полезными.

К этому же пункту относится мысль о том, что нет ничего лучше своего кода, написанного "от и до своими руками" (руками коллег), в отличие от готовых библиотек сомнительного происхождения.

Одна точка входа на сайт

Охранять одну дверь проще, чем сто дверей. Так заложите все, кроме одной, кирпичом. Т.е. оставьте одну точку входа на сайт, перехватывая обращения к ЛЮБОМУ URL’у, а затем обрабатывая запрос "своими силами". Затратно? Никак не затратнее устранения последствий нарушения безопасности. И на производительность влияет не так сильно, как могло бы казаться.

Как это сделать:

а) В .htaccess

RewriteEngine On
RewriteBase /
RewriteRule .* index.php?url=$0 [QSA,L]

б) В скрипте, анализирующем запрошенный URL, фильтруем его, т.е. приводим в нижний регистр и убираем последовательности вида %hh; идущие подряд две точки (".."); всё, что не является буквой английского алфавита, цифрой или знаками "_.,/".

$url = preg_replace("/%\d{2}|\.\.|[^a-z\d_\.\/]/", "", strtolower($url));
if (preg_match("/%\d{2}|\.\.|[^a-z\d_\.\/]/", $url)==1)
{
     // нашёлся кто-то слишком умный, посылаем подальше
}

Вторая проверка нужна против гениальных идей с комбинациями удаляемых значений, когда удаление части текста снова приводит к появлению недопустимой комбинации. Цикл лучше не делать, т.к. (да-да, мы должны быть параноиками) цикл может стать вечным.

в) Если запрошенный URL не заканчивается на слеш, получаем полное имя запрошенного файла в виде "путь к приложению"+URL. Проверяем, можно ли с нашего сайта запрашивать такие файлы (по расширению). Если да, то проверяем, есть ли такой файл в файловой системе. Если да, то выставляем правильный MIME-тип в заголовке "Content-type" и отдаём файл функцией fpassthru(), после чего завершаем скрипт.

Если такого файла нет, возможно, посетитель запросил не файл, а страницу сайта (проверим в дальнейшей работе движка).

Если файлы с таким расширением нельзя запрашивать, логируем ошибку и прекращаем работу.

WARNING! Злоумышленник может передать переменную url через GET-запрос в обход mod_rewrite. Если это очень нас напрягает, можно брать информацию о запрошенном ресурсе из $_SERVER[‘REQUEST_URI’] и анализировать. Однако, переданное злоумышленником всё равно попадёт в $_GET[‘url’] и будет проанализировано, как показано выше.

WARNING_2! Помните, что злоумышленник может специально передать некий вредоносный код (например, на JavaScript) в надежде, что он где-то запротоколируется, а потом выполнится при просмотре логов. Также может быть передан, например, PHP-код в надежде про-include’ить его потом и выполнить каким-то нетривиальным способом. Вывод: данные, попадающие в лог, тоже должны подвергаться тщательной фильтрации и обработке.

Если по какой-то причине создать одну точку входа на сайт нельзя или нежелательно, тщательно проверяйте во всех скриптах, действительно ли они вызваны должным образом (вошёл ли пользователь в систему, достаточно ли у него прав для выполнения требуемой операции, не пытается ли кто-то вызвать скрипт в обход системы авторизации или иного механизма обеспечения безопасности). В контексте этого примечания хочется дать совет "очень начинающим": если вы не уверены, что механизм защиты системы администрирования вашего сайта достаточно надёжен, используйте HTTP-авторизацию средствами .htaccess на доступ ко всему каталогу, в котором лежат скрипты системы администрирования.

Скрываем уязвимые данные

В подавляющем большинстве случаев ваше приложение будет установлено в некоторый подкаталог домашнего каталога пользователя (т.е., например, в /users/mycoolsiteuser/public_html/). Это значит, что у вас есть как минимум один уровень иерархии файловой системы, доступный вашим криптам, но заведомо недоступный по HTTP. Имеет смысл разместить файлы с паролями к БД и прочими важными данными в этом "защищённом от доступа по HTTP" каталоге.

К этому же пункту относится мысль о том, что думать следует не только о том, как ведут себя ваши скрипты, но и о среде, в которой они работают. Да, операционных систем очень много, и специфика везде своя, но проверить права доступа на те или иные каталоги и файлы можно (и нужно!) везде.

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

И ещё одно чудесное правило: НИКАКИХ ОТНОСИТЕЛЬНЫХ ПУТЕЙ в файловой системе. Ваше приложение при инсталляции должно прописать себе в конфигурационный файл пути ко всем своим каталогам (главному, с шаблонами, с обработчиками и т.п.) и ОБЯЗАТЕЛЬНО добавлять их в начало путей к соответствующим файлам, а потом ещё и обрабатывать полученное функцией realpath().

Любые данные, полученные от пользователя, проверяем, проверяем и ещё раз проверяем

а) Любой текст (будь то часть URL’а, поле формы, куки или что-то иное) проверяем на наличие недопустимых символов, тегов и т.п.

if (preg_match("/ПЛОХИЕСИМВОЛЫ/", $text)==1)
{
     // посылаем подальше
}

б) Любые данные проверяем на длину (как правило, проблемы возникают, когда длина равна нулю или превышает некоторое допустимое значение). Помним, что для мультибайтовых кодировок длина текста в символах может не совпадать с длиной текста в байтах, т.е.:

mb_strlen(‘Ляляля’, ‘UFT-8’) !== strlen(‘Ляляля’)

в) Числа проверяем на разрядность. Если число извлекается из строки, сначала проверяем его длину в количестве символов, чтобы нам не передали "10-мегабайтное число", которое гарантированно не поместится ни в какой числовой тип данных.

if (strlen($num)>8)
{
     echo "Ну сказано же: от 0 до 99999999";
}

г) Числа также проверяем на неравенство нулю и неотрицательность (если эти два свойства важны в смысловом контексте числа).

д) Если мы собираемся передать данные в БД, то проверяем их особенно тщательно: недопустимые символы, длины, кодировки, формат и т.п. Об этом — чуть ниже.

е) Если мы получили файл, имеет смысл проверить его размер, имя (только латиница, нижний регистр, буквы, цифры, знак подчёркивания, одна точка, обязательное расширение из списка допустимых). Если имя не соответствует требуемому — его можно привести к таковому. А некоторые разработчики рекомендуют не извращаться, а генерировать случайное имя: исходное имя в такой ситуации, при необходимости, можно хранить отдельно, например в БД, но проверьте сначала, чтобы файл не назывался "delete from admins" ;). В особо опасных ситуациях имеет смысл проверить не только имя, но и содержимое файла.

Примечание: часто приходится слышать возражения против столь строгих ограничений на имя файла (только латиница, нижний регистр, буквы, цифры, знак подчёркивания, одна точка, обязательное расширение из списка допустимых). Это правило я сформулировал для себя на основе многолетнего опыта. Если вам хочется большей свободы — на здоровье, это ваше приложение и ваш риск.

ё) Данные, которые будут отображены на страницах сайта, фильтруем на предмет HTML/JS/CSS — короче, на предмет любых тегов и всего того, что может выполниться на стороне клиента. Иначе банальное

<script language="JavaScript">
     window.location=’http://www.saitstroyanamiivirusami.com’
</script>

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

Чуть выше я писал о том, что вредоносные данные могут быть специально переданы для логирования и причинения вреда при просмотре логов. Есть способ защититься и от этого: пишите логи в файлы вне доступных по HTTP каталогах, а просматривайте банальным F3 в FAR’e.

ж) Во избежание атак на средства отправки почты (всевозможные формы обратной связи) проверяем на наличие (и удаляем) символы "\r" и "\n" в любых полях, полученных из формы (кроме текста сообщения). Иными словами, форма обратной связи должна позволять выбрать, кому пишется письмо, ввести заголовок и текст сообщения, которые подвергаются фильтрации. На всякий случай напомню простую истину: список "кому" должен содержать в своих

Помним о производительности

Чтобы сделать гадость, сайт не обязательно ломать инъекциями кода или чем-то подобным. Иногда его достаточно просто нагрузить (забавно, что это может произойти и просто из-за наплыва посетителей, не имеющих никакого злого умысла).

Поэтому ещё в процессе разработки добавляем в код отладочный механизм, показывающий нам, сколько времени генерировалась страница и выполнялись запросы к БД, сколько при этом было использовано памяти и т.п. Любые подозрительно большие значения в будущем выльются в проблему.

На стадии реальной эксплуатации сайта эти же параметры можно использовать для предсказания того, что "конец близок", и принятия мер: использования дополнительного кэширования, оповещения администратора и т.п.

Повысить производительность помогает грамотная архитектура проекта, позволяющая собирать и выполнять только те скрипты, которые необходимы для генерации данной конкретной страницы или выполнения данной конкретной операции (т.е. НЕ НАДО собирать для любого чиха мухи ВСЕ скрипты в один большущий мега-скрипт include’ами).

Да, само собой, следует провести полноценное нагрузочное тестирование, но сейчас речь не о нём.

Адаптация к изменениям

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

Периодически проверяем количество доступного дискового пространства. Логи, закаченные посетителями файлы, незабранная почта — всё это съедает доступное место очень быстро. Как только его количество опустилось ниже некоторого критического значения — оповещаем администратора, а если ситуация совсем плачевная — автоматически удаляем то, чем можно пожертвовать (старые логи, почта в спам-боксах и т.п.)

Приложение должно адаптироваться к настройкам среды или хотя бы проверять их. Можно при инсталляции собрать информацию о критичных для работы приложения параметрах среды, а затем по крону (раз в сутки, например) проверять, не изменились ли эти параметры; если изменились — уведомлять администратора. Получить настройки PHP можно с помощью функций ini_get() или ini_get_all().

Настройки: следим внимательно

Критические для безопасности и вообще работы настройки изменяем на нужные нам (везде, где это возможно), используя функцию ini_set():

а) register_globals — этой проблеме уже много лет: для обратной совместимости с "творениями отдельных гигантов" некоторые хостеры включают эту опцию; о том, к чему это приводит, есть много статей, не буду повторяться.

б) error_reporting и error_reporting(0) — есть такая опция, есть такая функция: помним, что на стадии реальной эксплуатации приложения никакие сообщения от интерпретатора PHP не должны быть показаны посетителям.

в) display_errors — дополнение к предыдущему пункту: тоже можно выключить.

г) allow_url_fopen и allow_url_include — выключаем; помним, что в любые include’ы или require’ы можно передавать ТОЛЬКО строковые константы или переменные, полученные из надёжного источника (например, настроек сайта в БД).

д) short_open_tag — тоже может создать вам проблему, если вы используете открывающий тег "<?" вместо рекомендуемого "<?php". Так что надо привыкать использовать то, что работает всегда по умолчанию: "<?php". Т.е. поставьте у себя на девелоперской машине это значение в Off (каюсь, сам так никогда не делаю; пару раз на эти грабли уже наступил).

е) output_buffering — при разработке обязательно отключить, чтобы полнее проверить работу всех функций, модифицирующих заголовки HTTP-ответа. При реальной эксплуатации можно и включить, но лично мне вариант с буферизацией "своими силами" кажется более оправданным.

ё) max_execution_time, max_input_time, default_socket_timeout — истечение любого из этих таймаутов приводит к неприятным последствиям, порой фатальным. Потому определяем значения этих настроек и учитываем при выполнении любой достаточно долгой операции.

ж) memory_limit — скрипты на PHP не очень прожорливы в плане используемой памяти: до тех пор, пока кому-то не придёт в голову чудесная идея дёрнуть из БД или с диска в память многосотмегабайтный запрос или файл. И всё. Интерпретатор не простит вашему скрипту такую вольность. Поэтому — перед "прожорливой" операцией имеет смысл проверить, хватит ли нам памяти для её выполнения.

з) max_input_nesting_level — не дайте злоумышленнику передать вам миллиардомерный массив 🙂

и) post_max_size, file_uploads, upload_max_filesize — если вы хотите, чтобы ваши пользователи могли закачивать файлы, не натыкаясь на каждом шагу на проблемы, подправьте эти настройки так, чтобы их значения соответствовали вашим ожиданиям.

й) session.use_trans_sid, session.use_only_cookies, session.bug_compat_42, session.bug_compat_warn — неверные значения этих параметров порой приводят к частичной или полной неработоспособности сессий.

Также ещё на стадии инсталляции следует проверить наличие и версии необходимых вам библиотек (через API самой библиотеки и доступность тех или иных функций (function_exists()). Также ничто не мешает проверить версию PHP, Apache, MySQL.

Облегчая разработку, не облегчайте взлом

Иногда для быстроты работы с проектом разработчики заводят некие "магические значения": пользователей с простыми паролями и полными правами, ссылки для входа в систему без аутентификации, жёстко прописывают в код "всегда подходящие пароли" и используют прочие "заглушки". Если забыть удалить что-то подобное на стадии эксплуатации, ничего хорошего не предвидится. А лучше — изначально этого не делать.

Ведём логи

Любые подозрительные ситуации имеет смысл протоколировать. Обращения к несуществующим объектам файловой системы (или таким, доступ к которым закрыт), ошибки аутентификации, обращения к несуществующим страницам сайта, ошибки отправки файлов, форм и т.п. Всё это может быть попыткой взлома. Изучая подобные логи, можно найти слабые места в безопасности вашего приложения. Про проблему с логами уже дважды сказано выше, не буду повторяться.

SQL-injections

Источники — любые внешние данные, а также (в худшем случае) неверное поведение самого приложения (вызванное внешними воздействиями или внутренними сбоями).

Что проверяем:

а) Запрошенный URL (сам URL и переменные GET).
б) Формы: содержимое полей, наличие необходимых и отсутствие лишних полей.
в) Куки.

Как защищаемся:

а) Прежде, чем передать что-то в СУБД, вы должны гарантированно убедиться, что в этих данных нет ничего опасного. Применяем фильтрацию данных везде, где это возможно, по самой полной программе. Очень хорошо в этом плане работают регулярные выражения.

б) Применяем функцию addslashes() (при этом помним о параметре magic_quotes_gpc: если он включён, применение этой функции даст "двойное экранирование").

в) Применяем функцию mysql_real_escape_string().

г) Прекрасный способ защититься от получения каких бы то ни было опасных для СУБД данных — использование хэширования. Например, строка с sha1-хэшем уже явно не будет выполнена как вредоносный запрос.

д) Иногда оправданной является модификация алгоритмов работы приложения с целью обработки данных на стороне бизнес-логики, а не на стороне данных: т.е. некий константный запрос проводит только выборку, а затем PHP-код проводит анализ. Альтернатива этого — использование хранимых подпрограмм (процедур и функций).

е) Для всего, где идёт ТОЛЬКО выборка, имеет смысл создавать т.н. "вьюшки" (представление, view), для которых заводить отдельного пользователя БД с минимальными правами.

ё) Перед выполнением запроса следует оценить (чаще всего — это возможно), сколько памяти и времени нам понадобится для его выполнения. Проверяем это и не даём выполнять "долгоиграющие тяжёлые запросы".

Закрываем обходные пути

Итак, вы купили хостинг, написали более-менее защищённое приложение и разместили его в сети. Сделайте это СО СВОЕГО компьютера, чистого от вирусов и троянов, перехватывающих пароли. Сделайте это в сети, администратор которой не анализирует трафик с целью перехватить чужие логины-пароли. Создайте отдельную учётную запись для работы по FTP, логин и пароль которой не совпадают с логином и паролем учётной записи у хостера. То же справедливо для любых других учётных записей: в СУБД, в почте и т.п.

P.S.

В предыдущих заметках этой серии я писал в конце краткие чек-листы. Здесь такового, увы, не будет, т.к. предметная область слишком обширна. Но если кто-то поделится готовым — с удовольствием размещу ссылку.