Skip to content

一些比较新的前端安全相关的技术点

2017年5月18日

本文为《Web前后端漏洞分析与防御》课程的配套文章。早年在慕课社区发布,现补发在博客上。

一说到安全,大家总会特别敏感,尤其是有相当部分的前端开发者并不了解安全相关的知识,颇有谈虎色变的感觉。具体到前端安全这个话题呢,又有些说不清道不明,因为大部分的防御方案,总少不了后端的参与,也有开发者慢慢觉得好像安全都应该由后端来关注了。

其实不然,起码 XSS CSRF 这一类的安全问题前端是一定要了解它们的原理和防御方法的。从防御方法上来说,XSS 和 CSRF 的防御在业界都有比较成熟的方案了。本文将记录一些比较新的防御方案,可能有一些比较老的书籍或者文章中不会提及这些方法。

XSS

XSS 全称 Cross Site Scripting ,跨站脚本攻击,因为 CSS 这名字老早就被样式表拿走了,大家都在 web 这个领域,重名又不好看,所以只好起了个名字叫 XSS 了。说实话从 XSS 干的事来讲,其实并不太理解为什么有个“跨站”在里面,如果一定要强行解释的话,大概是因为有可能会运行一个来自别人网站的脚本,把这个脚本叫“跨站”了吧。

好了,不重要。

重要的是它是谁,它从哪里来,要到哪里去……这样的哲学问题有点难回答。我们换一个,重要的是它是什么鬼,能干什么,怎么防。

XSS 是什么鬼

玩游戏的人都知道在游戏中经常出现一些奇葩的名字,比如“星辰并亲了他一口”,看上去一脸不知道什么鬼,但是当他有点事的时候,你就觉得好玩了,比如公会老大邀请他,就会有一条消息“公会老大邀请了星辰并亲了他一口”,然后一公会的人哄堂大笑。

其实这种案例用专业的术语来说,就叫 XSS 了……

本来“公会老大邀请了XX”这样一个句式,只希望XX是一个不会引起误会的名字而已,结果因为有一个奇葩名字,直接改变了整句话的意思,引介出了意外的含义。

XSS 也是同样的东西,比如我只想在页面上显示一个名字:

html
<span class="name">{{name}}</span>
<span class="name">{{name}}</span>

但是,如果我的名字是长这样的:

星辰<script>alert('SB')</script>
星辰<script>alert('SB')</script>

这时候就好玩了:

html
<span class="name">星辰<script>alert('SB')</script></span>
<span class="name">星辰<script>alert('SB')</script></span>

你看,页面中凭空多了一段脚本。这个例子还算善良的,只是弹出来骂了你一句……

“难道他还能弹出来打我?”

呃……当然不是啦,但是人家可以偷偷干坏事啊。你说说,你都用JS干嘛?用户登录用的JS吧,读取资料用的JS吧,点击买东西、消费用的JS吧,查用户有多少钱用的JS吧,基于 Cookies 也可以读写吧。好的,你用JS能干的事情人家都能干。

没事偷你个登录态,帮用户消费两块钱,查下你用户手机号是多少……接下来的事情我就不说了(无非就是前端程序员要背锅离职呗?)

XSS 怎么防御

一个经典的防御方法就是对内容进行转义和过滤,比如

javascript
var escapeHtml = function(str) {
	if(!str) return '';
	str = str.replace(/&/g, '&amp;');
	str = str.replace(/</g, '&lt;');
	str = str.replace(/>/g, '&gt;');
	str = str.replace(/"/g, '&quto;');
	str = str.replace(/'/g, '&#39;');
	// str = str.replace(/ /g, '&#32;');
	return str;
};

var name = escapeHtml(`<script>alert('SB')</script>`);
var escapeHtml = function(str) {
	if(!str) return '';
	str = str.replace(/&/g, '&amp;');
	str = str.replace(/</g, '&lt;');
	str = str.replace(/>/g, '&gt;');
	str = str.replace(/"/g, '&quto;');
	str = str.replace(/'/g, '&#39;');
	// str = str.replace(/ /g, '&#32;');
	return str;
};

var name = escapeHtml(`<script>alert('SB')</script>`);

此时 name 会变成

&lt;script&gt;alert(&#39;SB&#39;)&lt;/script&gt;
&lt;script&gt;alert(&#39;SB&#39;)&lt;/script&gt;

这样就会原样显示出来,再也无法耍流氓啦。

当然,富文本还要更麻烦一些,因为要保留一部分标签和属性,要不然全变纯文本了,就不富了。这种情况一般通过黑名单进行过滤,或者白名单放行。即只允许一部分指定的标签和属性,其它的全部转义掉。

CSP 大法

前面转义的方法的出发点,是让用户的输入不要变成程序,输入的什么就让它输出成什么。

事实上现代浏览器为我们带来了一个全新的安全策略,叫作内容安全策略,Content Security Policy,简称CSP。CSP的思路跟转义不一样,它的着手点是,如果一段代码变成了程序,我们是否应该运行它。或者更准确一点说,它实际上是定义页面上哪一些内容是可被信任的,哪一些内容是不被信任的。

因为我们自己的脚本是预先就知道并放在页面上的,所以我们可以设置好信任关系,当有 XSS 脚本出现时,它并不在我们的信任列表中,因此可以阻止它运行。

它的具体使用方式是在 HTTP 头中输出 CSP 策略:

Content-Security-Policy: <policy-directive>; <policy-directive>
Content-Security-Policy: <policy-directive>; <policy-directive>

从语法上可以看到,一个头可以输出多个策略,每一个策略由一个指令和指令对应的值组成。指令可以理解为指定内容类型的,比如script-src指令用于指定脚本,img-src用于指定图片。值则主要是来源,比如某个指定的URL,或者self表示同源,或者unsafe-inline表示在页面上直接出现的脚本等。

详细的指令和值,可以查看MDN相关页面

具体到上面的 XSS 例子,可以使用

Content-Security-Policy: script-src 'self';
Content-Security-Policy: script-src 'self';

这样除了在同一个域名下的JS文件外,其它的脚本都不可以执行了,自然之前 XSS 的内容也就失效啦。简单粗暴有没有?

当然,如果你说,我就是要在页面中放点内联的脚本,不可以么?当然可以啦,CSP 设计的时候也考虑了这些情况,还是相当灵活的。你只需要指定一个 nonce 属性,或者计算一下 hash 值,即可。详细的用法看 MDN 哦。

说实话,用 CSP 来处理 XSS 攻击还是不如转义来得优雅,因为转义可以不影响用户输入输出,不改变内容的本质。但是 CSP 提供了足够简单而又灵活的方式来防御 XSS ,可以很好地作为我们前端 XSS 防御的最后一道防线。

CSRF

CSRF 也是个望文生不到义的词,它的全称是 Cross Site Request Foggy,即跨站请求攻击。虽然也有跨站,但我觉得这个跨站还是相当可以理解的,它真的是从别的网站发起一个请求到我们的网站的。

当一个用户登录我们的网站后,在 Cookies 中会存放用户的身份凭证。在大部分时候,就是一个 SessionId 。当用户下次访问我们的网站的时候,我们用这个凭证识别出用户是谁,有没有登录态。

如果第三方网站的代码请求了我们的网站,会发生什么呢?比如

html
<img src="http://www.example.com/haha" />
<img src="http://www.example.com/haha" />

虽然它是一张图片,但它确实向www.example.com发了一个请求,如此用户有登录态的话,其实就相当于是用户自己发了一个请求。如果这个地址是一个发表文章、发布微博甚至转账之类的链接,那用户就在不知情的情况下进行了一些操作。这也是比较严重的安全问题。

当然你可能会说,现在谁还这么弱智,把这么敏感的操作用 GET 啊?没错,你可以选择用 POST ,但是这丝毫不能阻止 CSRF 攻击的发生啊。

html
<iframe name="test"></iframe>
<form target="test" method="post" action="http://www.example.com/haha">
    ...
</form>
<iframe name="test"></iframe>
<form target="test" method="post" action="http://www.example.com/haha">
    ...
</form>

当这个表单提交的时候,我们就发了一个 POST 请求。华丽丽的 CSRF 。

CSRF 的常规防御

CSRF 比较常规的防御方式是通过判断来源和加 token。

判断来源比较简单,主要是判断referer这个头,如果不是自己的网站,就返回错误。

加 token 即同样的随机 token,在 cookies 中放一份,在表单中再放一份。这样第三方网站就无法获取到这个 token 是什么。

但是这样做也有一个比较明显的问题,就是无法保证站内用户的体验。虽然你防了站外的攻击,但是也降低了站内用户的体验。具体表现在如果同时打开多个表单,只有最后一个表单能成功提交。

回想 CSRF 之所以能够攻击成功,核心原因就在于用户的身份是放在 Cookies 中的,而不管你通过什么方式访问网站,都会带上这个网站的 Cookies ,从第三方来的访问自然也不能例外。

但是,Chrome 在这个问题上给了我们不同的答案,可以放第三方访问时不带 Cookies 。也就是说 Cookies 只有本站能用,来自第三方的访问都不能使用。

具体的使用方式,是在打 Cookie 的时候,加上一个属性:SameSite,它的值有两:

  • strict 任何来自第三方的请求都不能使用 Cookies ,包括通过链接点进来的
  • lex 只有比较敏感的操作不带 Cookies ,比如表单提交

针对 CSRF ,我们可以将 Cookies 设置成SameSite: strict的,这样就可以有效防御 CSRF 了。不过比较可惜的是,目前只有 Chrome 才支持这一属性。希望未来所有浏览器都能跟上脚步。

使用 SameSite 还会面临一个问题,如果用户是点击链接进来的,那么是不能使用登录态的。一般可以考虑将用户不敏感的信息不设置这个属性,点进来仍然可以显示当前用户是谁,但是在请求的时候要求一个比较敏感的 SameSite 的 Cookies。这里需要更多的实践经验来探索。

小结

本文重点讲了 XSS 和 CSRF 这两种比较常见的前端安全问题的防御思路,尤其是如何使用一些新的规范、实现来帮助我们进行防御。希望后面浏览器对这些安全相关防御办法的普及率能再高一些,让前端工程师能花更少的时间写出更安全的代码。