Безопасный код: Работа с базой данных

Главные вкладки

Аватар пользователя neochief neochief 20 февраля 2009 в 19:34

Друпал предоставляет свои средства для доступа к базе данных. Это, во-первых, позволяет не зависеть от конкретного типа СУБД, а во-вторых, защититься от SQL инъекций. Самая первая функция, о которой следует узнать при работе с базой — db_query().

Начну, пожалуй, с примера, в стиле которого пишут почти все начинающие друпаллеры:

<?php
/**
* Пример 1 - небезопасный
* Пример должен отобразить список заголовков нод типа $type (например, поступающего из поля формы)
*/
$result = db_query("SELECT nid, title FROM node WHERE type = '$type'");

$items = array();
while ($row = db_fetch_object($result)) {
$items[] = l($row->title, "node/{$row->nid}");
}
return theme('item_list', $items);
?>

В этом примере сразу несколько вещей в корне неправильны.

Псевдонимы названий таблиц

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

<?php
$result = db_query("SELECT n.nid, n.title FROM {node} n WHERE n.type = '$type'");
?>

Что нам это даст? Это обеспечит простоту обработки таблиц с префиксами. То есть, если у вас все таблицы в базе называются "pr_node", "pr_users" и т.д., Друпал автоматически будет подставлять корректные префиксы к таблицам, заключенным в скобки. Указание псевдонимов при этом избавит от надобности использовать фигурные скобки больше одного раза.

Фильтрация аргументов

Отсутствует фильтрация аргументов запроса. Это прямой путь к SQL инъекции. Если в $type окажется значение story' UNION SELECT s.sid, s.sid FROM {sessions} s WHERE s.uid = 1/*, то весь запрос будет уже таким:

SELECT n.nid, n.title FROM {node} n WHERE n.type = 'story' UNION SELECT s.sid, s.sid FROM {sessions} s WHERE s.uid = 1/*'

что позволит мошеннику завладеть айдишниками сессий, и в свою очередь, при создании корректной куки сессии, получить прямой админский доступ к сайту.

Защититься от этого довольно просто, используя параметризацию запроса. При формировании запроса, Друпал использует синтаксис функции sprintf. В строке запроса вставляются заглушки, которые заменяются параметрами, которые идут отдельно. При этом параметры проходят проверку и экранирование, так что вы можете забыть об инъекциях, используя данный подход. Вот некоторые примеры:

<?php
db_query("SELECT n.nid FROM {node} n WHERE n.nid > %d", $nid);
db_query("SELECT n.nid FROM {node} n WHERE n.type = '%s'", $type);
db_query("SELECT n.nid FROM {node} n WHERE n.nid > %d AND n.type = '%s'", $nid, $type);
db_query("SELECT n.nid FROM {node} n WHERE n.type = '%s' AND n.nid > %d", $type, $nid);
?>

Список заменителей:

  • %d - для целых чисел (integers)
  • %f - для чисел с плавающей запятой, т.е. дробных (floats)
  • %s - для строк (однако, обратите внимание, что в запросе, вокруг строки выставляются кавычки)
  • %b - двоичные данные (не нужно оборачивать в кавычки)
  • %% - заменяется на % (например, для LIKE %monkey%)

Для конструкций IN (... , ... , ...), используйте функцию db_placeholders(), которая создаст нужную последовательность заменителей, по заданному массиву параметров, например:

<?php
$nids = array(1, 5, 449);
db_query('SELECT * FROM {node} n WHERE n.nid IN ('. db_placeholders($nids) .')', $nids);
?>

Если вы используете модуль Devel, у вас есть очень простой способ получения конечных запросов для отладочных целей. Просто вызовите функцию db_queryd() с точно такими же параметрами, как вы вызываете db_query().

Теперь, наш запрос будет выглядеть так:
<?php
$result = db_query("SELECT n.nid, n.title FROM {node} n WHERE n.type = '%s'", $type);
?>

Ранжирование результатов запроса

Наш пример на большом сайте выведет большущий список нодов. Что если нам можно ограничиться всего первым десятком? Первым позывом будет использовать SQL конструкцию LIMIT, например

SELECT n.nid, n.title FROM {node} n WHERE n.type = '%s' LIMIT 0, 10

и вроде бы все хорошо, но на Postgree SQL этот код приведет к ошибке, так как с этим сервером управления, вам нужно использовать конструкцию OFFSET 0 LIMIT 10. А еще на каком-нибудь Оракле, синтаксис опять другой. Что же делать?

Ответ — использовать db_query_range() для лимитирования количества результатов запроса. Его использование аналогично db_query, за исключением того, что в после всех аргументов, вам нужно указать два параметра — номер первой строки, и количество результатов. Наш запрос преобразится в следующее:

<?php
// выведет первых 10 результатов
$result = db_query_range("SELECT n.nid, n.title FROM {node} n WHERE n.type = '%s'", $type, 0, 10);
?>

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

<?php
/**
* Пример 2 - безопасный, с листалкой
*/
// изменяем сам запрос
$result = pager_query("SELECT n.nid, n.title FROM {node} n WHERE n.type = '%s'", $type, 10, 0);
// обратите внимание на смену очередности параметров. Здесь 10 это кол-во результатов,
// а 0 - номер стратовой страницы, а не результата, как было в db_query_range

$items = array();
while ($row = db_fetch_object($result)) {
$items[] = l($row->title, "node/{$row->nid}");
}

$output = theme('item_list', $items);

// добавляем листалку
$output .= theme('pager');

return $output;
?>

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

Возможность изменения запроса модулями

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

<?php
$result = pager_query(db_rewrite_sql("SELECT n.nid, n.title FROM {node} n WHERE n.type = '%s'", 'n', 'nid'), $type, 10, 0);
?>

а вот и пример реализации хука, для того, чтобы у вас было представление, что происходит:

<?php
// Модуль отсеет все ноды, авторы которых сутки не были на сайте
function my_module_db_rewrite_sql($query, $primary_table, $primary_field, $args) {
switch ($primary_field) {
case 'nid':
if ($primary_table == 'n') {
$return['join'] = "LEFT JOIN {users} u ON $primary_table.uid = u.uid";
$return['where'] = 'u.login > '. time() - 60 * 60 * 24;
}
return $return;
break;
}
}
?>
Возвращенные из хука 'join' элементы, будут прикреплены к нашему запросу, 'where' — добавлены к списку условий, и наш запрос после обработки будет таким:

SELECT n.nid, n.title FROM {node} n LEFT JOIN {users} u ON n.uid = u.uid WHERE n.type = '%s' AND u.login > 199976743

После этого, он, собственно, поступит в pager_query() и будет обработан как обычно.

Финальный код примера

<?php
/**
* Пример 3 - безопасный, с листалкой и возможностью перезаписи запроса
*/
// добавляем db_rewrite_sql
$result = pager_query(db_rewrite_sql("SELECT n.nid, n.title FROM {node} n WHERE n.type = '%s'", 'n', 'nid'), $type, 10, 0);

$items = array();
while ($row = db_fetch_object($result)) {
$items[] = l($row->title, "node/{$row->nid}");
}

$output = theme('item_list', $items);

$output .= theme('pager');

return $output;
?>

Полезные ссылки

Для поддержки на Хабре (черт, в плюсе от главной, докиньте же голосов Smile

Комментарии

Аватар пользователя neochief neochief 20 февраля 2009 в 19:42

Постарался описать по максимуму обо всем часто используемом. Теперь можно смело тыкать нубов в ссылку Smile

Аватар пользователя VladSavitsky VladSavitsky 20 февраля 2009 в 20:09

Это точно. Очень просто и понятно все расписано. Сейчас же займусь пейджером для запросов.
Спасибище огромное!

Аватар пользователя WiseMan WiseMan 20 февраля 2009 в 21:11

Реально полезно и доступно написано, а главное акцент хороший выбрали - "безопасный код".

Если будете издавать книгу, то я первый в числе покупателей Smile

Аватар пользователя seaji seaji 20 февраля 2009 в 22:01

Хочу сказать пару слов по поводу пейджера.
<?php
pager_query($query, $limit = 10, $element = 0, $count_query = NULL)
?>
Параметр $count_query должен в себе содержать отдельный запрос к базе, который даст общее количество строк результата запроса.

Здесь есть два аспекта. Если этот параметр не указывать, то это подходит только для простых запросов, которые не включают в себя множественные джоинты таблиц.
Но это всего лишь пол беды.
Вторая половина, это то, что создается дополнительный запрос к базе на предмет подсчета результата (я все оптимизацией болею)(нервно хихитает).
Где то я читал, что есть специальная команда, которая выводит общее количество строк результата запроса без сканирования таблиц. Сейчас найти ссылочку не могу. Чуть позже размещу эту инфу.

Аватар пользователя neochief neochief 21 февраля 2009 в 1:23

Да, я конечно же в курсе, просто не стал уводить слишком от первоначальной темы, кому надо, тот найдет уже сам Wink А книга да, в планах есть. Готовтесь к семерке.

Аватар пользователя neochief neochief 21 февраля 2009 в 3:13

Вячеслав, вы вероятней всего говорите об FOUND_ROWS()

Вот ваш код Smile

$select_query = "SELECT SQL_CALC_FOUND_ROWS n.nid, n.title FROM {node} n WHERE n.type = '%s'";
$count_query = "SELECT FOUND_ROWS()";
$result = pager_query($select_query, $type, 10, 0, $count_query);

Хотя, используя это, вы и получаете выигрыш в прорисовке первой странички, но в общем будет проигрыш, так как судя по нескольким комментам с друпал.орга, здесь не будет работать мускуловский кеш запросов (который бы закешировал нормальный COUNT запрос полсе первого раза). Кроме того, вы естественно, теряете в переносимости кода.

Аватар пользователя nleo nleo 21 февраля 2009 в 6:56

спасибо, как раз думал о том, что пора начинать пеуджинг использовать

хотелось бы еще увидеть подобную статью про программирование своих фильтров

Аватар пользователя seaji seaji 21 февраля 2009 в 10:24

"nleo" wrote:
хотелось бы еще увидеть подобную статью про программирование своих фильтров

Так зачем же ждать статью? Просто берите filter.module и препарируйте Smile Это более захватывающе чем статья, честное слово Smile

Аватар пользователя zhylik zhylik 21 февраля 2009 в 14:41

Друпал, к сожалению, будет некорректно работать с функцией found_rows(), т.к. в [ru-api=pager_query]pager_query()[/ru-api] сначала выполняется $count_query запрос, а только потом — основной Sad

Аватар пользователя neochief neochief 21 февраля 2009 в 16:29

zhylik, да, вы правы, не все так ажурно. Как бы там ни было, даже если сделать аналог pager_query() с нужной очередностью, мое замечание на счет производительности никуда не денеться.

Аватар пользователя Pilat Pilat 21 февраля 2009 в 23:03

Безопасным код будет, только если правильно работать с базами данных. Друпал работает с базами неправильно, к сожалению - это и наследство старого MySQL, и некоторые другие факторы.
Вопрос: можно ли как -то работать с базами, указывая запрос в форме
SELECT nid, title FROM node WHERE type = ?
Теория и практика говорит, что это самый правильный и безопасный способ, но может ли Drupal его поддерживать?

Аватар пользователя Demimurych Demimurych 22 февраля 2009 в 15:07

"seaji" wrote:
Вторая половина, это то, что создается дополнительный запрос к базе на предмет подсчета результата (я все оптимизацией болею)(нервно хихитает).

Далеко не всегда один запрос работает быстрее чем тот же поделенный на два простых.

Связано это чаще всего с тем каким образом mysql пытается оптимизировать запрос и что именно сейчас у него лежит в кеше.

Типичный пример
SELECT COUNT(*) FROM message WHERE uid = $user_id
и
SELECT COUNT(uid) FROM message WHERE uid = $user_id

Первый запрос работает в разы быстрее чем второй. Потому как для работы первого достаточно пройтись по индексу uid и посчитать кол во записей. А индексы у нас упорядочены и в большинстве случаев лежат в кеше.

Потому бывают ситуации когда 20! запросов могу работать быстрее чем один получающий тот же результат что и исходные 20.

Второй аспект,
Формат таблиц myisam

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

И так далее. Так что еще раз повторяю, Один запрос далеко не всегда быстрее чем даже 20 более простых.

Аватар пользователя Demimurych Demimurych 22 февраля 2009 в 23:54

"shp" wrote:
Demimurych, наверное, имелось в виду, что 2-ой запрос быстрее? :)

Нет. Не ошибся.

Именно первый запрос быстрее. Именно SELECT COUNT(*) FROM message WHERE uid = $user_id

Потому что этот запрос не приводит к выборе записи из базы. В нем задействован ТОЛЬКО индекс uid.

В запросе же
SELECT COUNT(uid) FROM message WHERE uid = $user_id mysql кроме идекса так же выбирает и всю запись из таблицы для того что бы извлечь от туда uid.

И это если я ничего не забыл, еще не учитывая того что в mysql select count(*) специльно оптимизирован.

Аватар пользователя Demimurych Demimurych 23 февраля 2009 в 1:22

"EzS" wrote:
но он выбирает все поля.

Странно...

http://dev.mysql.com/doc/refman/5.0/en/group-by-functions.html#function_...

COUNT(*) is optimized to return very quickly if the SELECT retrieves from one table, no other columns are retrieved, and there is no WHERE clause. For example:
mysql> SELECT COUNT(*) FROM student;

В данном случае * не означает выборку всех полей.

Аватар пользователя Stan.Ezersky Stan.Ezersky 23 февраля 2009 в 3:03

Так в мануале, явно указаны условия такой быстроты:

"Demimurych" wrote:
if the SELECT retrieves from one table

и
"Demimurych" wrote:
there is no WHERE clause

т.е. нет условия ГДЕ, как вашем примере:

"Demimurych" wrote:
Типичный пример
SELECT COUNT(*) FROM message WHERE uid = $user_id
и
SELECT COUNT(uid) FROM message WHERE uid = $user_id

______
/me потопал на mysql.com

Аватар пользователя Demimurych Demimurych 23 февраля 2009 в 12:00

"EzS" wrote:
т.е. нет условия ГДЕ, как вашем примере:

Цитату с mysql я вам привел для демонстраци того что такое count(*) а так же и для того что именно для count(*) есть персональная оптимизация для myisam таблиц. Да работающая в определенных условиях но работающая ОЧЕНЬ быстро.

То есть сказанное не отменяет того о чем я говорил. А дополняет.

во всем остальном Вам поможет EXPLAIN и/или собственные замеры на какой нить более менее приличной(по обьему) базе.

поймите главное, при использовании count(*) mysql нет необходимости выбирать записи из базы данных. Ей достаточно просто посчитать индекс.
Который чаще всего лежит в кеше.

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

Аватар пользователя nleo nleo 25 февраля 2009 в 23:06

<?php
//так не работает
$result = pager_query("SELECT n.nid, n.title FROM {node} n WHERE n.type = '%s'", 'image', 10, 0 );

//так работает
$result = pager_query("SELECT n.nid, n.title FROM {node} n WHERE n.type = 'image'", 10, 0 );
?>

хм.. ничего не понимаю