人人都能看懂的全栈开发教程——安全

人人都能看懂的全栈开发教程——安全

Chris Yue No Comment
Posts

距离我们的第一个里程碑还差一个需求,就是保存用户提交的任务内容。我们先添加 C 层文件 add.php 和 M 层文件 add-task.php,并写入下面的代码即可实现

<?php
// 这是 add-task.php 文件
$pdo = require __DIR__.'/pdo.php';

// sprintf 是另外一种链接字符串的方法,可去官网 php.net 查询用法
$sql = sprintf('insert into task values (\'%s\', \'%s\')', $content, date('Y-m-d H:i:s'));
$pdo->exec($sql);
<?php
// 这是 add.php 文件
// $_POST 变量包含了用户所传递的所有数据,也是一个数组,而字段正好与 HTML 元素的 name 一致
$content = $_POST['content'];

require 'add-task.php';

// header 方法用来添加响应给浏览器的 HTTP 头信息。关于 HTTP 头信息后面会说,总之这里的意思就是返回首页
header('Location: /');

为了方便我们测试,我们也在首页的模板文件 tasks.html.php 里添加了链接到 new.php 地址的超链接标签:

  ...  
  <a href="new.php">New</a>
</body>

现在再访问首页,点击左下方的 『New』到添加任务页面,随便写点任务内容并提交,跳回首页,如果一切顺利,会发现首页多了我们的刚才新添加的内容了!

这里先让我们高兴 5 秒钟,然后我就要开始泼冷水了:我们的网站,实际上有很多安全问题!

让我们再次去添加新的任务,这次输入的内容就是一句代码

<script>while(1)alert(111)</script>

提交自动返回到首页之后,惊不惊喜?意不意外?

想象一下,假如你做的不是任务列表而是一个论坛,如果有一个人发了这样一个帖子,其他所有浏览论坛的人都会被烦死。更严重的,直接写个跳转代码到钓鱼网站上。

解决方法还是有的,还记得之前介绍 HTML 时说过的『转义』的概念吗?当时说过在 HTML 源码里的空格不会输出到网页上,得用 &nbsp; 如果我们想让上面的输入不原样输出成 HTML 变成 HTML 代码的一部分,则我们也需要做类似的事情。

HTML 里有若干转义规则,网上搜『HTML entity list』你可以搜到转义列表,比如 < 对应的转义码是 &lt;。所以在把类似 <script> 这种字符串显示到页面前应当先把它变成 <script>

还有一个技术细节值得讨论:是先将用户的输入转义了之后再存在数据库,还是从数据库里输出到 HTML 页面的时候再转义?大家可以先思考一下再往下看。

在我从业这么多年来,两种做法的项目我都见过,但对我个人来说,我的建议是输出为 HTML 代码之前再转义。我理解存库前转义的想法,因为转义是需要消耗运算资源的,先转好再放在数据库里,可以节省显示时再转的运算资源,并且网页被浏览的次数肯定比添加数据的次数多得多。但我反对这么做是因为,我希望数据库里保存的是用户提交的原始数据,并且 HTML 转义很明显只是跟 HTML 或者说网页有关,但如果你的数据不是在网页里展现的呢?比如说在命令行下,是不是还得要转回去?

说到这还有一种折中的方法,就是数据库里存一份原始的存一份转义的,这看上去是一种双赢的办法,缺点依然是有的:第一是数据存储量变多了,当然对于现在的硬盘来说,不是什么大事;第二是要维护的字段变多了,在实现同一个需求前提下,能维护的字段当然越少越好。

在技术讨论中,大家担心的一些问题,往往都不一定是大问题,就好比上面的问题,实际测测就能发现,现在的 CPU 是如此之快,转义所消耗的时间,对比从数据库里读取数据所花的时间,完全不是一个数量级。从我的经验来说,技术人员讨论问题时如果遇到矛盾,尽量不要用缺乏数据支撑的事情当自己的观点,否则很容易把不值得一提的小事变大(或者相反),影响整个团队的判断。

这里我们也就决定在输出到页面的时候再转义。另外要感谢 PHP 内置 htmlspecialchars 函数,让我们不需要自己去写转义的代码逻辑。我们先把它用上,看看效果:

<?php
// 依然是 tasks.html.php
...
      <?php foreach ($rows as $row): ?>
        <li><?= htmlspecialchars($row['content']) ?></li>
      <?php endforeach ?>
...

除了 htmlspecialchars,PHP 还有一个类似功能的函数叫 htmlentities,它们的区别仅在于能转义的字符的范围不同,htmlentities 能转义的字符更多一些,包括很多不能直接用键盘打出来的 ASCII 码,比如 ¥。虽然 htmlspecialchars 只转义 < > & ",实际上对于不影响页面的显示来说,已经完全足够。

让我们刷新首页,不出意外任务内容都会按照输入的内容显示了;查看网页源代码也会发现所有的 < > 都被替换,大家也可以试试添加包含 &" 的任务,看最后被转换成了什么。

我们再来看看第二个安全问题。依然去添加新任务,这次的内容是

say 'hi'

看起来是很普通的一句话,但你会发现,根本就加不上!这是为什么呢?

答案其实很简单。我们的 SQL 语句,如果拼上刚才我们输入的内容变成了什么,大家可以先想一下。

如果想象力不够丰富想不出来,那么也可以实际看一下 $sql 变量变成了什么:

<?php
// 在 add-task.php 里做如下修改:
...
$sql = sprintf('insert into task values (\'%s\', \'%s\')', $content, date('Y-m-d H:i:s'));
var_dump($sql); die(); // 这是我们临时添加的代码
$pdo->exec($sql);

当我们遇到问题时,需要追踪问题是怎么导致的,比如我们现在遇到的添加不了数据的问题,就可以先确认一下我们的 SQL 语句到底是什么。

如果你们照做了,应该会返回:

string(59) "insert into task values ('say 'hi'', '2020-01-31 11:11:54')" 

能看出来 SQL 语句有什么问题吗?如果还看不出来,就直接把字符串打到 mysql-client 里看看报什么错。

我们还有另外一种方法查看到底什么问题,我们设置一下 PDO 的报错模式,如果 SQL 出错就直接抛出异常:

...
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

无论是在 mysql 客户端里尝试,还是设置了 pdo 的报错模式,他们报错的信息都应该是一样的:

You have an error in your SQL syntax ……

意思就是语法有问题。实际上我们的问题还是很明显的,因为内容里带有单引号,导致我们的 SQL 语句的单引号已经无法配对了。

我们所遇到的问题只是保存不了数据,后果还不是最严重的。我跟大家说说最经典的案例。假如说我们有一个系统,需要登录才能进入,登录的时候肯定都需要帐号和登录密码。如果我查找帐号以及登录密码的 SQL 写成

$sql = sprintf('select * from user where name = \'%s\' and password = \'%s\' limit 1');

如果网站攻击者知道有一个用户名叫 chris,他将用户名写成 chris' or '1' = '1 ,密码随便填一个,拼出来的 SQL 语句是

select * from user where name = 'chris' or '1' = '1' and password = '乱填的密码' limit 1

需要注意的是,这个 SQL 语句完全是合法的!并且会返回名称为 chris 的数据,这样我即使没有账户,但也可以随意登录任意一个人的账户。可能有些同学已经知道,这就是大名鼎鼎的『SQL 注入』。

SQL 注入也是很早就有的事儿了,PDO 是有自己的防范措施的,下面我们还是回到我们自己的例子,看如何应对。

我们继续改进 add-task.php 文件,改完之后如下:

<?php

$pdo = require __DIR__.'/pdo.php';

$statement = $pdo->prepare('insert into task values (:content, :date)');
// execute 方法自己会处理好单引号问题之后再替换上面的『占位符』即冒号开头那些
$statement->execute(['content' => $content, 'date' => date('Y-m-d H:i:s')]);

解决掉 SQL 注入的风险后,我们再来看下一个安全问题。可能大家不知道,浏览器是可以关闭掉运行 JS 代码的功能的,这里先不说浏览器怎么关闭运行 JS,先强调一下,网站在什么样的环境下运行,你是不知道的,所以一个需要大家重视的建议是,客户端做的一切数据检查,都是不可信的,比如我们检查用户的任务内容不能提交空内容,实际上把浏览器的 JS 运行功能关闭之后依然是可以提交的。所以我们还是需要在服务器端——对我们的项目来说也就是在 PHP 代码里再次检查提交内容是否为空。

<?php
// 在 add-task.php 最前面加上下面这句
if (empty($content)) {
    die('提交内容不能为空');
}

// 因为需求并没有讨论说如果系统检查到提交内容为空我们应该如何处理
// 所以我们先简单通过 die 终止程序运行,die 函数的参数是终止之前回显到终端,或者打印到页面上的字符串

可能有人会说了,反正最终都要靠后台来检查,为啥还需要前端检查呢(包扩用 JS 或者 HTML5 自带的 required 检查)?理由是,在网站实际运营中,正常用户都是不会去关 JS 的功能的,或者不会专门去找一个 HTML5 都不支持的浏览器。当有用户不小心没有写内容直接点击提交,因为被 JS 阻拦,就不会发起新的请求到服务器,节省一次对服务器的请求,并且前端很容易做比后端界面更友好,更丰富的错误提示效果。总结来说就是,前端检查为用户,后端检查防小人。

来到最后一个安全问题。这次我们直接在地址栏里输入 http://localhost:8000/console/list.php,我们便通过网页直接访问了一个本来应该在命令行下运行的 PHP 文件。

可能你会说,这有啥问题呢?我们现在因为需求太简单,所以看不出问题来,但假如,我们网站所提供的服务是需要用户登录访问的,用户也只能看到自己的任务,但命令作为一个运维工具,列出了所有人创建的任务,是不是就意味着其他用户的隐私被暴露了呢?

这个问题出现的最本质原因是:命令行下运行的 PHP 文件就不应该跟网站访问的文件在同一个目录下。实际上,我们不但应该对网站用户隐藏 console 下的文件的访问,除了控制器文件,其他所有文件都是不应该被访问到的。只能说还好我们还没有实现读取配置文件功能,如果我们用了 INI ,XML,JSON 等文件格式写配置文件,我们的 Web 服务就不会像对待 PHP 文件一样去解析它,而是直接输出内容,用户实际上是可以直接打开或者下载我们的配置文件的,假如我们把数据库地址密码作为配置信息放里面,那网站的敏感信息就被泄露了。

为了解决这个问题,我们可以将控制器文件单独放在一个目录里,当然为了维护方便,M 和 V 我们也这么操作一下。完成之后,应当是这样一个目录结构:

.
├──   config.php
├──   console
│  └──   list.php
├──   model
│  ├──   add-task.php
│  └──   tasks.php
├──   template
│  ├──   new.html.php
│  └──   tasks.html.php
└──   web
   ├──   add.php
   ├──   index.php
   └──   new.php

其中 model 就是我们的 M 层,template 即模板,也就是我们的 V 层,而 web 就是用户能直接访问的地方也就是我们的 C 层。

改完之后,我们应当停止 Web 服务,进到 web 目录里面再重新开启。

最后,因我们的路径变化,需要修改我们的控制器文件包含其他文件的路径。以 index.php 为例,修改为

<?php

$rows = require '../model/tasks.php';
include '../template/tasks.html.php';

当然我们的 model/task.php 在引入配置文件时的路径,也应当做修改。这里就不再举例大家自己改就行了。

所有工作全部完成之后,除了控制器文件,其他文件全部都在 web 目录外面,所以再也无法被用户直接访问了。

安全问题属于经验问题,你知道的安全问题越多,做好的预防越多,你的网站被攻击的可能性就越低。我不可能把所有的安全问题都在一篇文章里全列出来,但我会在后面的教程里找机会,继续跟大家分享各种各样的安全问题及其对策。

本章改动较多,请大家访问这里查看本章完整的代码。

人人都能看懂的全栈开发教程——安全 by Chris Yue is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

微信赞赏码

写作累,服务器还越来越贵
求分担,祝愿好人一生平安
天使打赏人

发表评论

24 − = 19