Изучаем PHP. Выявление и логирование ошибок: стабильность и безопасность сайта
Давайте поговорим о безопасности при обработке данных, связанной с выводом отладочной информации в браузер посетителя. Очень часто, при обработке клиентских данных весь вывод отладочной информации, как и ошибок, программисты отправляют в браузер посетителя, совершенно не задумываясь при этом о дальнейшем профилировании проекта и его усовершенствовании, а также о том, что своими же руками они предоставляют конфиденциальные технические данные работы проекта любому желающему, да и попросту раздражают этим рядового пользователя.
Это при том, что PHP уже давно обладает богатым набором функций и средств для логирования, журналирования и сбора ошибок и непредвиденных ситуаций. Кроме этого часть ошибок так и остается «за кадром», которые можно выявить и устранить уже в процессе работы, тем самым повышая стабильность сайта и избегая потери новых посетителей, которые, столкнувшись с определенными трудностями и ошибками, попросту уйдут на другой ресурс.
Итак, перейдем к сути вопроса. Допустим, у нас есть страница, отображающая новости раздела, в контексте которой согласно id страницы $_GET[‘ limitfrom’] категории $_GET[‘id’] мы выбираем количество новостей $_GET[‘limit’] из связанной таблицы материалов `news`.
Обычно (утрировано и упращенно) это выглядит, например, так:
<?php $id = $_GET['id']; $limitfrom = $_GET['limitfrom']; $limit = $_GET['limit']; $sql = 'SELECT * FROM news WHERE category_id =' . (int)$id.' LIMIT '. (int)$limitfrom.','.(int)$limit; $resource = mysql_query($sql); If($resource && is_resource(resource)){ //обработка данных } else { echo mysql_error(); die(); } ?>
Пример «взят» не из воздуха, вот так, приблизительно, но в более сложной форме выглядит разбитие на постраничную навигацию в разделах известного новостного движка DLE. А также вот так (echo mysql_error(); die()) выводится, зачастую, в браузер посетителя возникновение mysql ошибки в Joomla 1.5 и более новых версий (чуть сложнее, но смысл остается тот же).
Здесь была осознанно допущена одна оплошность. Дело в том, что данный вариант будет работать отлично! И в повседневной работе программист не увидит выполнение echo mysql_error().
Но почему не следует выводить в браузер отладочную информацию и ошибки исполнения PHP?
Итак, первое, из за того, что мы обходимся без проверки, что же приходит со стороны клиента, например, отрицательные значения $id и $limit, которые допускаются для типа данных integer, или несуществующие значения приведут к выводу отладочной информации mysql_error(). Итак, ясным по белому, мы дадим информацию злоумышленнику о существующих таблицах и пищу для размышления, как же проще осуществить mysql injection в другом месте, где используются эти же таблицы при составлении запросов. Или же в некоторых случаях покажем посетителям техническую информацию, которую видеть они не должны. С одной стороны здесь нам помогут более строгие проверки входных данных. Перепишем этот пример с использованием выше сказанного:
<?php $id = abs((int)$_GET['id']); $limit = abs((int)$_GET['limit']); $limit = $limit ? $limit : 10; $limitfrom = abs((int)$_GET['limitfrom']); $sql = 'SELECT * FROM news ' .($id && !empty($id) ? 'WHERE category_id='. $id : '') .( $limitfrom && !empty($limitfrom) ? ' LIMIT '. $limitfrom.', '. $limit.'' : ' LIMIT '. $limit.''); $resource = mysql_query($sql); If($resource && is_resource(resource)){ //обработка данных } else { echo mysql_error(); die(); } ?>
Мы предотвратили, как минимум, возникновение нескольких исключительных ситуаций. Мы знаем, что нам нужны только лишь целые положительные значения $id,$limit – поэтому используем явное приведени типов для этих переменных, оператор приведения к целому (int) и функцию получения целого числа abs(). Также мы знаем, что количество выбранных новостей не должно быть равно нулю, поэтому определяем минимальный «порог» в 10 новостей $limit = $limit ? $limit : 10;, формируя запрос, мы проверяем данные, и если они есть и отличны от 0, то добавляем их в mysql запрос по частям.
Все бы хорошо, но и здесь могут быть подводные камни. И все равно существует некоторая вероятность того, что при манипуляции с входными данными злоумышленник увидит вывод той самой информации echo mysql_error();. Зачастую это может получиться даже чисто случайно.
Другой распространенный пример. Многие программисты добавляют вывод технической информации еще на этапе подключения к базе данных. Что, в случае отсутствия соединения с базой данных (сверх нагрузки/временной недоступности сервера баз данных) также выводит отладочную информацию посетителям. При этом там, зачастую, могут в простом тексте встречаться даже логин/название базы/хост подключения к базе данных. А сам программист может никогда и не увидеть подобной информации, и даже не узнать о временном полягании проекта (например из за превышения числа подсоединений к базе данных в случае с увеличением аудитории, как итог: при частом повторении проблемы потеря новых посетителей, раскрытие важных технических данных).
А ведь сбор такой информации бывает полезным для профилирования приложения и дальнейшего его усовершенствования (обеспечения стабильности работы). Для выявления «подводных» камней в стабильной работе сайта.
Итак, давайте обратимся к документации по php. К разделу о создании собственных исключений и перенаправлении отладочной информации и ошибок, логировании и журналировании.
Какие функции и данные для логирования в PHP могут быть полезны?
- trigger_error – для генерации исключения.
- ini_set , error_reporting – для подавления вывода ошибок
- set_error_handler – для установки собственной функции «перехвата» ошибок
- register_shutdown_function – для отлова критических ошибок (которая работает, как нужно, начиная с php 5.2)
- error_get_last – функция получения последней ошибки, произошедшей в скрипте, очень полезна в случае прерывания скрипта из за возникновения критической ошибки
- $_SERVER – суперглобальный массив – источник информации – где и когда и какая ошибка произошла
- getenv – функция для получения значения переменных среды окружения сервера, в том случае, если какие-то данные в суперглобальном массиве $_SERVER отсутствуют
Давайте напишем свой перехватичк исключительных ситуаций для дальнейшего логирования данных об ошибках. Суть заключается в следующем, мы переопределим вывод отладочной информации с помощью собственной функции в текстовый файл в определенной папке /errors, чтобы его не смогли прочесть извне, присвоим ему расширение .php (можно и при помощи запрета прямого доступа извне к этой папке, но данный метод более универсален для любых серверов и не требует дополнительной настройки окружения сервера) и добавим первой строчкой <?php die(«Forbidden.»); ?>, название же зададим ему, совпадающее с текущей датой, чтобы было проще ориентироваться. В итоге один файл ошибок – одна дата.Также мы добавим «перехват» критических ошибок. И при помощи функции trigger_error для логирования непредвиденных ситуаций и генерации собственных ошибок типа E_USER:
Примечание автора: желательно подключить подобный код как можно раньше, еще на этапе инициализации созданного php приложения:
<?php error_reporting(0); ini_set('error_reporting','0'); ini_set('display_errors', '0'); ini_set('display_startup_errors', '0'); ini_set('ignore_repeated_errors', '1'); define ('ROOT_PATH', dirname(dirname(__FILE__))."/"); //запретить/разрешить вывод ошибок define('_ERR_HANDLING',true); //где будем хранить файлы ошибок define('_ERR_DIR',ROOT_PATH.'errs/'); function error_reporting_log($error_num, $error_var=null, $error_file=null, $error_line=null) { $error_desc= ''; $error_desc = 'Error'; switch ($error_num){ case E_WARNING: $error_desc = 'E_WARNING'; break; case E_USER_WARNING: $error_desc = 'E_USER_WARNING'; break; case E_NOTICE: $error_desc = 'E_NOTICE'; break; case E_USER_NOTICE: $error_desc = 'E_USER_NOTICE'; break; case E_USER_ERROR: $error_desc = 'E_USER_ERROR'; break; case E_ERROR: $error_desc = 'E_USER_ERROR'; break; default: $error_desc = 'E_ALL'; break; } $date_file = date('y-m-d H:I:S'); $logfile= LOG_FILE; $url = $_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']; $date_time = date('d.m.y - H:i:s'); $ip= isset($_SERVER['REMOTE_ADDR']) && !empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : getenv('REMOTE_ADDR ') ; $from= isset($_SERVER['HTTP_REFERRER'])&& !empty($_SERVER['HTTP_REFERRER'])? $_SERVER['HTTP_REFERRER'] :getenv('HTTP_REFERRER'); $errortext = $error_desc.': '.$error_var."\t".' Line: '.$error_line."\t".' File: '.$error_file."\t".' Link: '.$url."\t".' Date: '.$date_time."\t".'IP: '.$ip."\t".' FROM:'.$from."\n"; unset($from,$error_desc, $error_var,$error_line, $error_file,$url,$date_time,$error_write); $secuire= '<?php die("Forbidden."); ?>'; if(is_file($logfile)&&is_writeable($logfile)){ $fp = fopen($logfile,'r'); if($fp && is_resource($fp)){ $strings= fgets($fp); if(isset($strings)&&!empty($strings) &&strpos($strings,$secuire)===false){ unlink($logfile); } fclose($fp); }; unset($fp); } if(!is_file($logfile)){ $dir= dirname($logfile); if(is_dir($dir)&&is_writable($dir)){ $fp = fopen($logfile,'w+'); if(is_resource($fp)){ flock($fp,LOCK_EX); fwrite($fp,$secuire."\n"); flock($fp,LOCK_UN); fclose($fp); $fp= null; } unset($dir,$fp); } } unset($secuire); if(is_file($logfile)&&!is_writable($logfile)){ chmod($logfile,0775); } if(is_file($logfile)&&is_writeable($logfile)){ $fp = fopen($logfile,'a+'); if(is_resource($fp)){ flock($fp,LOCK_EX); fwrite($fp,$errortext); flock($fp,LOCK_UN); fclose($fp); $fp= null; unset($fp); } } unset($logfile); return true; } function err_handler(){ if(_ERR_HANDLING){ $error_reporting= ''; $error_reporting= ini_get('error_reporting'); $error_reporting= $error_reporting?$error_reporting:E_ALL; error_reporting(E_ERROR); $date_file = date('dmY').'.php'; $dir= _ERR_DIR; $path= $dir.$date_file; $logfile= ''; if(!is_dir($dir) || !is_writable($dir)){ if(is_dir($dir)&&!is_writable($dir)){ chmod($dir,0775); } else if(!is_dir($dir)){ $isdir= false; $isdir= mkdir($dir,0775); } if(!$isdir&&!is_writable($dir)){ $dir= ROOT_PATH; $path= $date_file; } } if(is_dir($dir) && is_writable($dir)){ if(!is_file($path)){ $fp= fopen($path,'w+'); if($fp && is_resource($fp)){ $secuire= '<?php die("Forbidden."); ?>'; flock($fp,LOCK_EX); fwrite($fp,$secuire."\n"); flock($fp,LOCK_UN); fclose($fp); $fp= null; unset($secuire); } } if(is_file($path) && !is_writable($path)){ chmod($path,0775); } if(is_file($path) && is_writable($path)){ ini_set('display_errors',0); set_error_handler('error_reporting_log', (E_ALL & ~E_NOTICE)); $logfile= $path; define('LOG_FILE',$logfile); } unset($date_file,$dir,$path,$logfile); } error_reporting($error_reporting); unset($error_reporting); } } function critical_error(){ $error = error_get_last(); if(is_array($error) && sizeof($error) && isset($error['type']) && $error['type'] == E_ERROR && !empty($error['message']) && !empty($error['file']) && !empty($error['line'])){ error_reporting_log($error['type'], $error['message'],$error['file'],$error['line']); } } if(function_exists('register_shutdown_function') && function_exists('error_get_last') && _ERR_HANDLING){ register_shutdown_function('critical_error'); } err_handler();
Что здесь происходит? При помощи функции ini_set и error_reporting мы подавляем вывод ошибок в браузер посетителя, константами определяем местоположение нашей директории для хранения логов ошибок define(‘_ERR_DIR’,ROOT_PATH.’errs/’);. Определенная в самом начале константа (bool) _ERR_HANDLING – при значении true разрешит перенаправление всех наших ошибок в файлы, при значении false дальнейшие инструкции выполняться не будут. Для перехвата самих ошибок используется функция error_reporting_log, которая принимает тип ошибки, сообщение об ошибке, имя файла, где произошла ошибка, и строку, в которой она произошла. В ней мы собираем кроме этой информации, и другие полезные данные. А именно:
- $_SERVER[‘HTTP_REFERRER’] — откуда пришел посетитель, с какой страницы?
- $_SERVER[‘REMOTE_ADDR’] — ip посетителя,
- $_SERVER[‘HTTP_HOST’]. $_SERVER[ ‘REQUEST_URI’] – полный адрес страницы вместе со строкой запроса, на которой возникла ошибка
- date(‘y-m-d H:I:S’) — текущее время возникновения ошибки.
Зачастую, подобной информации хватит с головой и для логирования и исправления ошибок, которые были не замечены на этапе разработки, так и для отлова злоумышленников. Но есть один тип ошибок, который не будет перехвачен. Это критические ошибки PHP E_ERROR, которые приводят к прерыванию php скрипта . Начиная с версии PHP 5.2 для перехвата критических ошибок можно определить собственную функцию при мопощи register_shutdown_function. Мы создаем собственную функцию для этих целей critical_error, в которой при помощи функции error_get_last получаем информацию о текущей ошибке. Так как данная функция будет всегда выполняться после завершения php скрипта, мы логируем информацию только об ошибках с типом E_ERROR.
Можно было бы использовать функцию error_log с указанием пути к источнику логирования, но было отдано предпочтение простым файловым функциям, при помощи которых можно установить функцией flockприоритетное эксклюзивное запирание во избежание попыток одновременной записи в логфайл несколькими скриптами (и возникновения «битых» файлов).
Давайте на примере все того же скрипта с mysql рассмотрим принцип логирования mysql ошибок (впрочем, по такому же принципу можно построить логирование любых внештатных ситуаций):
<?php $id = abs((int)$_GET['id']); $limit = abs((int)$_GET['limit']); $limit = $limit ? $limit : 10; $limitfrom = abs((int)$_GET['limitfrom ']); $sql = 'SELECT * FROM news ' .($id && !empty($id) ? 'WHERE category_id='. $id : '') .( $limitfrom && !empty($limitfrom) ? ' LIMIT '. $limitfrom.', '. $limit.'' : ' LIMIT '. $limit.''); $resource = mysql_query($sql); If($resource && is_resource(resource)){ //обработка данных } else { $string = print_f( 'Ошибка mysql при получении новостей из категории с ID: %d,' . 'на странице: %d , ' . 'информация о запросе: %s,' . ' информация об ошибке: %s', $id,$limitfrom, $sql, mysql_error()); trigger_error($string,E_USER_ERROR); die(); } ?>
Вот и все, в файле ошибок при возникновении ошибки mysql будет добавлена новая строка с подробной информацией. Таким способом вы сможете логировать практически любую полезную для вас информацию, скрыв ее от Ваших посетителей. А сбор и анализ подобной информации поможет в дальнейшем в повышении стабильности и совершенствовании Вашего проекта.
Примечание автора: также для генерации и перехвата ошибок Вы можете использовать конструкции try{}catch(Exeption $e){ thrown(throw new Exception(«$name contains the word name»);}
Примечание автора: правилом хорошего тона при перехвате и обработке ошибок (особенно критических, которые ведут к невозможности предоставить посетителю необходимую информацию, за которой он, собственно, и пришел к Вам на сайт) является создание для каждого вида подобных ошибок статической страницы с объяснением на человекопонятном языке без технических данных сути проблемы (также на такой странице можно оставить контакты админитрации) и перенаправление посетителя на нужную страницу в ходе возникновения ошибки.
Например, в приведенном примере с категориями можно создать страницу с подобным текстом:
Уважаемый посетитель!
Новости данной категории временно недоступны или отсутствуют, возможно, часть новостей была удалена, так как устарела, перемещена в архив (ссылка на архив) или другую категорию, Вы пришли к нам по несуществующей ссылке. Перейдите, пожалуйста, на главную страницу сайта (ссылка на главную страницу сайта) или в начало данной категории (ссылка в начало категории), также вы сможете воспользоваться поиском по сайту (ссылка на поиск), или обратите внимание на наши информационные блоки (якорная ссылка на информационные блоки), если Вам необходима именно эта информация, свяжитесь с нами (контактные данные).
Просим Вас уведомить нас о сложившийся ситуации любым удобным для Вас способом
(контактные данные)
Во первых: ваши же посетители будут четко, понятно и информативно ставить Вас в известность о произошедшей ситуации, во вторых: тем самым Вы сбережете их нервы, и даже в случае возникновения непредвиденной ситуации посетители могут продолжить изучать Ваш ресурс.
Информация к размышлению, что можно сделать лучше в этом скрипте?
- Предусмотреть архивацию/удаление данных логирования по истечению определенного периода.
- Предусмотреть помесячную отсылку подобных данных для анализа администратору проекта на email, jabber.
- Предусмотреть сбор и вывод логируемой информации в браузер для определенных пользователей после авторизации (например, экстренная отладка для админа).
- Заменить функциональный подход расширяемыми классами.