关于 PHP 应用程序中安全性的说明
PHP Security Consortium
虽然本文表明在使用 PDO 时不再需要引用输入,但这不是说您应该盲目地使数据通过数据库。XSS 攻击是很实际的危险。您应该总是确保对传入应用程序的不受信任的数据应用适当的过滤器,并采取措施避免让不受信任的数据在站点上发出 HTML 或 javascript。
请访问 The PHP Security Consortium 以了解关于这些危险的更多知识,以及应该如何避免这些危险。
很多 PHP 脚本中一个常见的缺陷是缺乏输入检验。这种缺陷可以被利用,从而招致 XSS(Cross Site Scripting)以及 SQL 入侵攻击。在 SQL 入侵中,不受信任的数据(例如发给 Web 网页的反馈)和其他文本被衔接在一起,构成一个查询。攻击者可以蓄意地安排他们的输入,使之溢出引号之外,并在您想运行的真正查询后面链接上任意一个查询。这种攻击使攻击者可以更新、插入或删除数据,甚至可能可以看到数据库中的任意信息。
XSS 也是一个类似的问题。不过这一次不受信任的数据瞄准的是浏览站点的人们,而不是应用程序本身。通过提交包含 HTML 或 javascript 组合的文本,攻击者期望您之后会将那种数据直接输出到其他访问站点的人那里,从而使恶意代码可以在站点访问者的浏览器上运行。
在编写应用程序时,需要同时考虑这两种攻击。如果小心地检验和过滤输入,这两种攻击都是可以防止的。对 XSS 的处理很有技巧性,所以在这里我不便多讲(不过可以从侧栏找到有用的参考资料)。相比之下,SQL 入侵更容易对付。您只需在构造查询之前,适当地排除每块不受信任的数据。这种事情有点烦杂,特别是当您有大量的字段要处理时,很容易忘记做这件事。
虽然这是有用的(并且也是重要的)信息,但是您可能想知道,为什么我要花时间提到这一点,本文的重点不是结合使用 PDO 和 DB2 吗?原因是这样的:PHP 现在得到很广泛的部署,自然地,大量流行的基于 PHP 的应用程序也得到了广泛的部署。每当某一种这样的应用程序(和 PHP 本身没有联系)被发现存在漏洞时,PHP 常常被误认为是不安全的,可被利用的或者有缺陷的。为了避免将来出现这样的情况,我们可以采取的一个措施是鼓励应用程序开发人员多考虑安全问题,从而减少由诚实的错误导致的损害。扯远了,下面继续介绍其他关键概念。
预处理语句和存储过程
很多更成熟的数据库都支持预处理语句的概念。什么是预处理语句?您可以把预处理语句看作您想要运行的 SQL 的一种编译过的模板,它可以使用变量参数进行定制。预处理语句可以带来两大好处:
查询只需解析(或准备)一次,但是可以用相同或不同的参数执行多次。当查询准备好后,数据库将分析、编译和优化执行该查询的计划。对于复杂的查询,这个过程要花比较长的时间,如果您需要以不同参数多次重复相同的查询,那么该过程将大大降低应用程序的速度。通过使用预处理语句,可以避免重复分析/编译/优化周期。简言之,预处理语句使用更少的资源,因而运行得更快。
提供给预处理语句的参数不需要用引号括起来,驱动程序会处理这些。如果应用程序独占地使用预处理语句,那么可以确保没有 SQL 入侵发生。(然而,如果您仍然将查询的其他部分建立在不受信任的输入之上,那么就仍然存在风险)。
预处理语句是如此有用,以致 PDO 实际上打破了在目标 4 中设下的规则:如果驱动程序不支持预处理语句,那么 PDO 将仿真预处理语句。
下面是使用预处理语句的两个例子。第一个例子 通过替换指定占位符的 name 和 value,执行一次插入。而 第二个例子 使用问号占位符执行一条 select 语句。
清单 4. 使用预处理语句的重复插入
$stmt = $dbh->prepare("INSERT INTO REGISTRY (name, value) VALUES (:name, :value)");
$stmt->bindParam(':name', $name);
$stmt->bindParam(':value', $value);
// insert one row
$name = 'one';
$value = 1;
$stmt->execute();
// insert another row with different values
$name = 'two';
$value = 2;
$stmt->execute();
清单 5. 使用预处理语句取数据
$stmt = $dbh->prepare("SELECT * FROM REGISTRY where name = ?");
if ($stmt->execute(array('one'))) {
while ($row = $stmt->fetch()) {
print_r($row);
}
}
如果数据库驱动程序支持,您还可以绑定输出和输入参数。输出参数通常用于从存储过程获取值。输出参数使用起来比输入参数要复杂一些,当绑定一个给定的输出参数时,必须知道该参数的长度。如果为参数绑定的值大于您建议的长度,那么就会产生错误。
清单 6. 带输出参数调用存储过程
$stmt = $dbh->prepare("CALL sp_returns_string(?)");
$stmt->bindParam(1, $return_value, PDO_PARAM_STR, 4000);
// call the stored procedure
$stmt->execute();
print "procedure returned $return_value\n";
您还可以指定同时具有输入和输出值的参数,其语法类似于输出参数。在接下来的例子中,字符串 'hello' 被传递给存储过程,当存储过程返回时,hello 被替换为该存储过程返回的值。
清单 7. 带输入/输出参数调用存储过程
$stmt = $dbh->prepare("CALL sp_takes_string_returns_string(?)");
$value = 'hello';
$stmt->bindParam(1, $value, PDO_PARAM_STR|PDO_PARAM_INPUT_OUTPUT, 4000);
// call the stored procedure
$stmt->execute();
print "procedure returned $value\n";
错误和错误处理
PDO 提供了 3 种不同的错误处理模式,以满足不同风格的编程:
PDO_ERRMODE_SILENT
这是默认模式。PDO 将只设置错误代码,以通过 errorCode() 和 errorInfo() 方法对语句和数据库对象进行检查。如果错误是由于对语句对象的调用而产生的,那么可以在那个对象上调用 errorCode() 或 errorInfo() 方法。如果错误是由于调用数据库对象而产生的,那么可以在那个数据库对象上调用上述两个方法。
PDO_ERRMODE_WARNING
除了设置错误代码以外,PDO 还将发出一条传统的 E_WARNING 消息。如果您只是想看看发生了什么问题,而无意中断应用程序的流程,那么在调试/测试当中这种设置很有用。
PDO_ERRMODE_EXCEPTION
除了设置错误代码以外,PDO 还将抛出一个 PDOException,并设置其属性,以反映错误代码和错误信息。这种设置在调试当中也很有用,因为它会放大脚本中产生错误的地方,从而可以非常快速地指出代码中有问题的潜在区域(记住,如果异常导致脚本终止,则事务将自动回滚)。
异常模式另一个有用的地方是,与传统的 PHP 风格的警告相比,您可以更清晰地构造自己的错误处理,而且,比起以静寂方式以及显式地检查每个数据库调用的返回值,异常模式需要的代码/嵌套也更少。
PDO 定制了使用 SQL-92 SQLSTATE 错误代码字符串的标准;不同 PDO 驱动程序负责将它们本地代码映射为适当的 SQLSTATE 代码。例如,SQLSTATE 是用于 DB2(以及通常的 ODBC)的本地错误代码格式,这是多么方便啊!errorCode() 方法返回一个 SQLSTATE 代码。如果您需要关于一个错误的更多特定的信息,PDO 还提供了一个 errorInfo() 方法,该方法将返回一个数组,其中包含 SQLSTATE 代码、特定于驱动程序的错误代码以及特定于驱动程序的错误字符串。
分页数据、滚动游标和定位更新
在 Web 应用程序中,一种常见的范例是对查询结果进行分页。如果您使用一个 Internet 搜索引擎,那么很可能每天都会做这样的事。您输入搜索词,然后得到前 10-20 个匹配项。如果您想看到更多搜索结果,可以单击 "next page" 链接。如果想回头看前面看过的结果,可以单击 "previous page" 链接。记得在几年前,当我第一次在 Web 上使用这样的东西时,我对自己说:“为什么我不能通过滚动查看所有数据呢?” 问题的答案说简单也简单,说复杂也复杂 —— 我只想说,HTTP 不会智能地使数据库上的可滚动游标一直处于开放状态,即便如此,需要大量传输的 Web 应用程序也会很快地消耗掉大量开放的可滚动游标。因此,最简单的解决方案是为用户显示所有的匹配项 —— 但是用户很容易迷失在大量的结果当中。比较符合逻辑的措施是人工地将数据格式化到多个页面上,使用户可以每次查看一部分可以管理的数据。
所以人们编写可以取所有数据的 PHP 应用程序,然后只显示前 10 行。根据下一次请求,应用程序又显示 11-20 行,依此类推。这对于只返回少量数据的查询来说很不错,但是,如果有很多匹配项(比如多于 100),那么先取全部数据然后丢弃其中的 90%,这种做法很浪费。PHP 的创始人 Rasmus Lerdorf 就这种情形特地为 MySQL 发明了一个特殊的 "LIMIT, OFFSET" 子句。它允许您通知数据库,您只对一小部分行感兴趣,这样它就不会取其他不需要的行了。其语法(或非常类似的东西)已经被其他流行的开放源代码数据库采纳,但并不是所有数据库都提供了相同的语法。 Troels Arvin 收集了一些非常有用的信息,对不同 RDBMS 所支持的语法进行了比较。
如果您想在以 DB2 为后台数据库的 PHP 应用程序中实现分页结果,那么可以(也应该)使用下面示例中的语法。这里我们假设有一个 books 表,表中包含书名和作者,我们现在想要每次在一页中显示 10 个以上结果:
清单 8. 使用 SQL Standard "Window Functions" 实现数据分页
$db = new PDO('odbc:SAMPLE', 'db2inst1', 'ibmdb2');
// the offset is passed in from the user when they click on a link
// this cast to integer ensures that no SQL injection can occur
$offset = (int)$_GET['offset'];
$stmt = $db->prepare("select * from (
select
ROW_NUMBER() OVER (ORDER BY author) as rownum,
*
from books
) as books_window
WHERE rownum > $offset AND rownum <= (10 + $offset)");
if ($stmt->execute()) {
while (($row = $stmt->fetch()) !== false) {
print_r($row);
}
}
Cloudscape 说明
在撰写本文之际,Cloudscape 在其 SQL 实现中还不支持 ROW_NUMBER(),所以需要使用可滚动游标。
现在,如果您要编写一个更通用的应用程序,并希望实现分页的结果集,但是不想专门编写很多的代码,并且也不想使用更重量级的抽象层,Troels Arvin 的非常有帮助的 RDBMS 信息建议,您可以使用游标作为更轻便(稍微慢一点)的方案。碰巧的是,PDO 具有这方面的 API 级的支持。下面将谈到如何使用这种支持来达到与上面示例相同的效果:
清单 9. 使用滚动游标实现数据分页
$db = new PDO('odbc:SAMPLE', 'db2inst1', 'ibmdb2');
$stmt = $db->prepare("select * from books order by author", array(
PDO_ATTR_CURSOR => PDO_CURSOR_SCROLL));
// the offset is passed in from the user when they click on a link
// this cast to integer ensures that no SQL injection can occur
$offset = (int)$_GET['offset'];
if ($stmt->execute()) {
// moves the cursor to the requested offset and fetches the first
for ($tofetch = 10,
$row = $stmt->fetch(PDO_FETCH_ASSOC, PDO_FETCH_ORI_REL, $offset);
$row !== false && $tofetch-- > 0;
$row = $stmt->fetch(PDO_FETCH_ASSOC)) {
print_r($row);
}
}
需要强调的是,虽然滚动游标对于更冗长的 window 函数方案来说是一个很方便的替代方案,但这种方案要慢很多。如果在一个传输量比较少的环境中进行测试,您可能发现不了速度上的差异,但当规模扩大时,您就会开始发现速度降慢带来的痛苦。
定位更新
可滚动游标的另一个用途是,基于 SQL 中无法表达的重大标准驱动更新。如果您有一个 Web 页面链接的表,并且需要在每晚的批处理过程中更新那个表,以反映 Web 页面当前大小,那么可以编写如下代码:
清单 10. 使用滚动游标作出定位更新
$db = new PDO('odbc:SAMPLE', 'db2inst1', 'ibmdb2');
// create a named, scrolling, updateable cursor
$stmt = $db->prepare("select url, size from links FOR UPDATE OF size", array(
PDO_ATTR_CURSOR => PDO_CURSOR_SCROLL,
PDO_ATTR_CURSOR_NAME => 'link_pos'));
if ($stmt->execute()) {
// a statement for applying our updates.
// Notice the WHERE CURRENT OF clause mentions "link_pos",
// which is the name of the cursor we're using to select the data
$upd = $db->prepare("UPDATE links set size = ? WHERE CURRENT OF link_pos");
// grab each row
while (($row = $stmt->fetch()) !== false) {
// There are much more efficient ways to do this;
// this is a brief example only: grab all the content
// from the URL
$content = file_get_conents($row['url']);
// and measure its length
$size = strlen($content)
// and pass that as a parameter to our update statement
$upd->execute(array($size));
}
}
大型对象
在应用程序中的某个地方,您可能发现需要在数据库中存储“大型(large)”数据。大型通常意味着“大约 4kb 或 4kb 以上”,尽管在没有“大型”数据之前 DB2 最大可以处理 32kb 的数据。 大型对象可以是文本的,也可以是二进制的。PDO 允许在 bindParam() 或 bindColumn() 调用中通过使用 PDO_PARAM_LOB 类型代码来使用大型数据类型。PDO_PARAM_LOB 告诉 PDO 将数据映射为流,所以可以使用 PHP Streams API 来操纵这样的数据。下面是一个示例:
清单 11. 从数据库取一副图像
$db = new PDO('odbc:SAMPLE', 'db2inst1', 'ibmdb2');
$stmt = $db->prepare("select contenttype, imagedata from images where id=?");
$stmt->execute(array($_GET['id']));
list($type, $lob) = $stmt->fetch();
header("Content-Type: $type");
fpassthru($lob);
上面的介绍很简明扼要。现在让我们试试另一面,将上传的图像插入到一个数据库中:
清单 12. 将图像插入数据库中
$db = new PDO('odbc:SAMPLE', 'db2inst1', 'ibmdb2');
$stmt = $db->prepare("insert into images (id, contenttype, imagedata) values (?, ?, ?)");
$id = get_new_id(); // some function to allocate a new ID
// assume that we are running as part of a file upload form
// You can find more information in the PHP documentation
$fp = fopen($_FILES['file']['tmp_name'], 'rb');
$stmt->bindParam(1, $id);
$stmt->bindParam(2, $_FILES['file']['type']);
$stmt->bindParam(3, $fp, PDO_PARAM_LOB);
$stmt->execute();
这两个例子都是宏观层次的。请记住,被取的大型对象是一个流,可以通过所有常规的流函数来使用它,例如 fgets()、fread()、fgetcsv() 和 stream_get_contents()。