使用db_query()来确保查询语句的安全
译者:老葛 Eskalate科技公司
攻击网站的一个常见方式称为SQL注入。让我们看一个没有考虑安全性的人编写的模块。这个人仅仅想用一种简单的方式列出特定类型节点的所有节点的标题:
/*
* Implementation of hook_menu().
*/
function insecure_menu($may_cache) {
$items = array();
if ($may_cache) {
$items[] = array(
'path' => 'insecure',
'title' => t('Insecure Module'),
'description' => t('Example of how not to do things.'),
'callback' => 'insecure_code',
'access' => user_access('access content')
);
}
return $items;
}
/*
* Menu callback, called when user goes to http://example.com/?q=insecure
*/
function insecure_code($type = 'story') {
// SQL statement where variable is embedded directly into the statement.
$sql = "SELECT title FROM {node} WHERE type = '$type'"; // Never do this!
$result = db_query($sql);
$titles = array();
while ($data = db_fetch_object($result)) {
$titles[] = $data->title;
}
// For debugging, output the SQL statement to the screen.
$output = $sql . theme('item_list', $titles);
return $output;
}
访问http://example.com/?q=insecure,一切正常。我们得到了SQL,接着是故事(story)列表,如图20-2所示。
图20-2。 简单的故事节点标题列表
注意,这个程序员是如何聪明的将insecure_code()函数中的$type参数默认设为'story'(‘故事’)。这个程序员也掌握了Drupal的菜单系统,路径中额外的变量将自动作为参数传递给回调函数,所以路径http://example.com/?q=insecure/page将为我们得到类型为'page'(‘页面’)的节点的所有的标题,如图20-3所示。
图20-3 简单的页面节点标题列表
然而,该程序员犯了一个潜在的致命错误。将变量$type直接放到SQL中并且依赖PHP的变量扩展,这样整个站点就完全暴露了。让我们访问http://example.com/?'%20OR%20type%20=%20'story(参看图20-4)。
图20-4 在db_query()中不使用占位符引起的SQL注入
哎哟!我们可以在URL中输入SQL并执行它!一旦你让用户能够修改发送给数据库的SQL,那么你的站点很容易受到攻击。下面是改进后的版本:
function insecure_code($type = 'story') {
$sql = "SELECT title FROM {node} WHERE type = '%s'"; // Always use placeholder.
$result = db_query($sql, $type); // db_query() will sanitize placeholder.
$titles = array();
while ($data = db_fetch_object($result)) {
$titles[] = $data->title;
}
$output = $sql . theme('item_list', $titles); // Titles not sanitized
return $output;
}
现在当我们视图操纵URL时,db_query()将通过对单引号进行转义从而清理输入的数值。查询语句将变成下面的样子:
SELECT title FROM node WHERE type = 'page/' OR type = /'story'
由于我们没有名为"page/' OR type = /'story"的节点类型,这个查询很明显的会失败。然而,这仍然是个坏的实践,因为在这种情况下URL应该仅包含一个有限集的成员;也就是说,我们站点上的节点类型。我们知道都有哪些类型,所以我们需要确认用户提供的值是我们知道的值中的一个。例如,如果我们仅启用了page和story两种节点类型,只有当URL中提供了这些类型时我们才继续处理。让我们添加一些代码用来检查这一点:
function insecure_code($type = 'story') {
if (!in_array($type, node_get_types())) {
watchdog('security', t('Detected possible SQL injection attempt.'),
WATCHDOG_WARNING);
return t('No such type.');
}
$sql = "SELECT title FROM {node} WHERE type = '%s'";
$result = db_query($sql, $type);
$titles = array();
while ($data = db_fetch_object($result)) {
$titles[] = $data->title;
}
// Apply check_plain() to all array members.
$titles = array_map($titles, 'check_plain');
$output = $sql . theme('insecure', $titles);
return $output;
}
function theme_insecure($titles) {
return theme('item_list', $titles);
}
这里我们添加了一个检查,用来确保$type是我们的已存在的节点类型中的一个,而且我们为系统管理员记录了一个方便的警告。我们还将用于结果显示的功能分解为一个单独的主题函数,这是一个更加Drupal的方式;这样任何人都可以通过定义一个新的主题函数来覆盖默认的输出(参看第8章)。还有,由于标题是用户提交的数据,我们在输出它们之前使用check_plain()对其进行处理。但是这还有一个安全漏洞。你能找到它么?如果不能的话,请看下文。
使用db_rewrite_sql()来保持私有数据的私有性
在前面的例子中列出节点对于第3方模块来说是个常见的任务(现在的用的少一些了,这是由于使用views模块可以很方便的通过web定义节点列表)。你可能会问:如果站点启用了节点访问控制模块,在前面的例子中哪段代码是用来保证我们的用户仅看到允许他们看到的节点集?这个问题问得很好,确实没有相应的代码。前面的代码将展示给定节点类型的所有节点,即便是节点访问模块限制访问的节点。这段代码非常傲慢,它没有考虑其它模块的感受!让我们修改一下代码。
修改前:
$result = db_query($sql, $type);
修改后:
$result = db_query(db_rewrite_sql($sql), $type); // Respect node access rules.
我们使用db_rewrite_sql()来对传递给db_query()的SQL参数进行了包装,函数db_rewrite_sql()允许其它模块修改SQL。核心模块中的一个重要例子就是节点模块,它改写针对表node(节点)的查询语句的。它首先检查表node_access中是否存在可能限制用户访问节点的记录,然后向SQL中插入查询语句片段用来检查这些权限。在我们的这种情况下,节点模块将修改SQL,它向WHERE语句中插入一个AND查询片段,从而过滤掉用户无权访问的节点。如何做到这一点,以及更多关于db_rewrite_sql()的信息,请参看第5章。
动态查询语句
如果在你的SQL中,有多个只有在运行时才能确定的变量,这并不妨碍让你使用占位符。你将需要使用占位符字符串比如'%s' 或者 %d,通过编程来创建你的SQL,接着你需要传递一组值来填充这些占位符。如果你自己直接调用db_escape_string(),那么你就做错了。下面的例子展示了占位符的使用,这里假定我们想取出匹配特定节点类型的已发布节点的节点ID和标题:
// $types is an array containing one or more node type names
// such as page, story, blog, etc.
$count = count($types);
// Generate an appropriate number of placeholders.
$placeholders = array_fill(0, $count, "'%s'");
$placeholders = implode(',', $placeholders);
// $placeholders now looks like '%s', '%s', '%s'...
$sql = "SELECT n.nid, n.title from {node} n WHERE n.type IN ($placeholders)
AND status = 1";
$result = db_query(db_rewrite_sql($sql), $types);
运行完db_rewrite_sql()以后,db_query()的调用看起来的样子如下所示:
db_query("SELECT DISTINCT(n.nid), n.title from {node} n WHERE n.type IN
('%s','%s') AND status = 1", array('page', 'story');
现在当db_query()执行时,将清理节点类型的名称。如果你想知道具体的缘由,可参看includes/database.inc中的db_query_callback()。
下面是另一个例子。有时你处在这样的一种情况,你想在一个查询的WHERE语句中添加一些AND限制条件来限制查询语句。此时,你也需要小心点使用占位符。在下面的例子中我们假定$uid 和 $type的值都是有效的(例如,3和page(页面))。
$sql = "SELECT n.nid, n.title FROM {node} n WHERE status = 1";
$where = array();
$where_values = array();
$where[] = "AND n.uid = %d";
$where_values[] = $uid;
$where[] = "AND n.type = '%s'";
$where_values[] = $type;
$sql = $sql . ' ' . implode(' ', $where) ;
// $sql is now SELECT n.nid, n.title
// FROM {node} n
// WHERE status = 1 AND n.uid = %d AND n.type = '%s'
// The values will now be securely inserted into the placeholders.
$result = db_query(db_rewrite_sql($sql), $where_values));