循序渐进理解:跨源跨域,再到 XSS 和 CSRF

2020年06月23日 • 更新于 2022年01月05日

看见标题的那一刻的你,内心会不会犯嘀咕:怎么回事? 为什么要在同一篇文章里提到跨域、XSS、CSRF? 你没有看错。不仅如此,本文还一并介绍鲜有人关注的 OAuth state 参数。

关于本文

我为什么要把这些概念,浓缩在同一篇文章里?

纵观各类文章,通常会把这几个概念拆开,分成几篇文章介绍。但是对于读者来说, 收获的知识是片面的,没有建立统一清晰的认识,只会让人云里雾里。

实际上,这些概念不应该割裂地看待,需要相互促进理解。我分析了一下原因:

  • 要想理解为什么限制跨源,就必须知道 CSRF 攻击;想要理解 CSRF,就需要先知道什么是跨源。
  • 在了解跨域方案时,需要建立足够的安全意识。

有了这些基础,还可以轻松理解 OAuth 登录流程中 state 参数的作用。

对于这些概念,我们只需要梳理最关心的原理。 所以为了方便理解,省略了一些技术层面上的细枝末节。 其中将运用一些罕见的思路:

  • 澄清跨源和跨域两个概念。
  • 规范使用「第一方」和「第三方」两个术语。

一些约定

注意: 很多情况下,我们常说的跨域,更应该被准确的称为跨源。 因为跨域 (cross-domain) 和跨源 (cross-origin) 是两个概念。 所以,当该用「跨源」的时候,本文统一用「跨源」描述,而不是习惯用的「跨域」。 只有真正该说跨域的时候 (比如 cookie 跨域),才使用跨域一词。

我在文中会习惯性地称被攻击网站 (受害网站) 为 A,攻击者利用的网站称为 B。

本文划分为跨源问题和攻击问题两大类,各节内容环环相扣,阅读每节需要前一节的基础知识,不建议跳读。

跨源请求

我们先从几个简单的问题开始思考。

什么是跨源?什么是跨域?

从 URL 的构成中,可知 (协议 域名 端口) 三元组。当两个 URL 的 (协议 域名 端口) 三元组 完全一致时才是同源;否则就是跨源 (cross-origin)。具体规则和例子可以参考 MDN 解释什么是源 (origin)

至于跨域 (cross-domain),则只考虑两个 URL 中的域名是否相同,无需比较协议和端口。

是谁判断一个网络请求是否跨源?

浏览器。

浏览器出于安全原因,需要判断一个请求是否跨源,这是浏览器的 强制行为。 现代浏览器为了保障用户的安全,必须检查跨源请求是否安全。

本文将一直强调 从浏览器的角度 看待问题的重要性。

浏览器判断哪些请求会跨源?

首先考虑一下浏览器有哪些请求。浏览器加载了页面,也就是地址栏中 URL 所指向的 HTML 文档, 这是该页面的第一个请求。请注意此时 页面的源

所有剩下的请求,包括<script><style>标签加载脚本和样式, <image>标签加载图片等外部资源,以及 POST 表单发送的请求,JS 脚本发送的请求等: 只要请求的 URL,和页面 URL (地址栏中的 URL) 不满足同源关系,就是跨源请求。

另外要注意的是,跨源引入的 JS 脚本,该脚本向 页面的源发送的请求,不算跨源; 同源引入的 JS 脚本,也可以发送跨源请求。源是 (协议 域名 端口) 三元组,跟该请求 来自哪个脚本无关。所以判断跨源,应该只比较请求的 URL 和页面的源。

跨源请求会被阻止?

从上面的问题中可以知道,跨源是件很普遍的事情。 无论是加载外部资源,还是表单发送 的 POST 请求,都不受任何限制。浏览器认为跨源加载资源和表单 POST 请求是合理的, 因为离开它们就不会有多姿多彩的互联网,所以不会干涉它们。

浏览器 只限制 JS 的跨源请求,这被称为 同源策略,意即限制 JS 请求在同一个源下。 浏览器认为 JS 发送的请求不安全, 所以检查 JS 请求的跨源是否合法。 而且,浏览器的同源策略 不限制写操作和资源嵌入,而是 限制读操作。请求能正常发送, 但是对响应的读取被拦截。具体内容在下个问题中。

而且,我在说的是 JS 脚本中发送的请求,而不是为了载入 JS 脚本这个资源所发送的请求, 因为上面说过加载外部资源是不受限制的,所以不要混淆了。

至于同源策略只限制 JS?当然不完全是,比如@font-face引入跨源字体,也要受同源策略影响。 允许跨源字体和允许 JS 跨源请求的处理方式是一样的,见后文。 另外,考虑一下crossorigin属性。由于有这些额外情况的存在,我决定在另一篇文章中谈论,所以 本文为了理解方便,不会再提及。

很多开发者,是从跨源请求被拦截,进而导致网站功能不正常,才认识跨源的。 所以畏惧跨源,抱怨跨源,网上充斥着 如何绕过同源策略的文章。最后,很多人知道该怎么跨源,但还是不清楚为什么浏览器 要限制跨源。实际上,跨源本身不是问题。

我们该关心的是,CSRF 正是处于跨源的情况下进行攻击的。 因为有网络攻击的存在,浏览器才会作出如此多的限制,而不是 故意为了恶心开发者。 当然浏览器作出的努力远远不够,依旧难以防范 CSRF,这些内容将在本文后半部分涉及。

浏览器怎么阻止跨源?

浏览器负责拦截响应,服务器决定阻止策略。 所以,想要让浏览器支持 JS 请求跨源,必须得到服务器的支持。关于服务器方面是如何支持的, 见下文中的 允许跨源请求的方法 一节。

需要特别注意的是,浏览器只是拦截了响应,跨源请求依旧发送出去了。服务器端可能受理了它。

更进一步说,跨源请求到达服务器,如果服务器选择拦截跨源请求,那么服务器会返回错误; 如果服务器没有拦截跨源请求,而是选择接受请求并响应它,接下来 CORS 设置会随同响应返回 给浏览器,浏览器会遵守服务器方的 CORS 设置,对跨源请求的响应选择接受或者抛弃。

多出来的 CORS 设置,是服务器方告知浏览器,如何处理跨源请求的设置。如果服务器没有 CORS 设置, 浏览器采取默认行为,在跨源请求发送并得到响应后,抛弃响应。额外提醒:不要把 CORS 设置和 CSP 设置搞混淆哦!

但是,所说的这个流程 只涉及了简单请求。对于非简单请求,浏览器在发送跨源请求前,需要先 通过一个额外的请求,提前得到服务器的 CORS 设置。此时,CORS 设置可以从一开始就 阻止浏览器发送跨源请求的行为。如果服务器返回错误的 CORS 设置或者根本就没有 CORS 设置, 那么,浏览器也不会发送跨源请求。

什么是简单请求?

浏览器需要区分一个跨源请求是不是简单请求 (simple request)。简单请求 是 MDN 文档为了方便讨论所采取的术语。本文在此也沿用此术语。可以直接看 MDN 解释简单请求

简单请求: 当跨源请求是简单请求, 浏览器直接发送该请求。服务器在返回响应的时候,在响应头部中带上 CORS 设置。

非简单请求: 当跨源请求不是简单请求, 浏览器在发送该请求前,需先通过发送一个预检请求 (preflight request), 获取服务器的 CORS 设置。这是浏览器的自动行为,JS 也无需 自己发送预检请求。

多出来的预检请求使用 OPTIONS 方法。当然预检请求也是一次跨源, 但不会受到任何限制。真正需要检查和限制的请求,是之后实际要发送的跨源请求。

浏览器获取服务器对预检请求的响应后,检查该响应中的 CORS 设置, 如果认为实际要发送的跨源请求符合 CORS 设置,则发送这个跨源请求; 如果认为不符合要求,则不发送跨源请求。

所以为了发送一个合法的非简单的跨源请求,总共需要发送两个请求。 当然,服务器的 CORS 设置可以被浏览器缓存, 所以不需要浏览器每次发送跨源请求之前发送预检请求。

跨源请求可以携带 cookie 吗?

可以。<image>等标签的载入资源的请求,是一定会带上 cookie 的。在 JS 中的请求, 默认不带 cookie,但可以通过设置一个字段,也能带上 cookie。所以,所有请求都能携带 cookie。CSRF 攻击也能做到。

虽然,这种说法还是有些不严谨。下文关于 cookie 的内容会提到 cookie 的 SameSite 标记, 可以修改浏览器这一默认行为。

跨源请求安全吗?

不安全!即使服务器没有任何 CORS 设置,一个简单的跨源请求也可能被服务器处理, 只不过是浏览器抛弃了响应而已。跨源请求可能导致服务器使用一些非幂等方法, 而且可能被 CSRF 攻击利用! 所以,服务器端必须认清跨源请求可能带来的风险!

允许跨源请求的方法

我想很多人最关心的就是这部分内容了。 因为对于从没接触过跨源的人来说,最急切的就是寻找解决方案。 在有上文的铺垫后,对于理解这部分的内容会感到相当轻松。

CORS

依旧推荐阅读 MDN 文档:HTTP 访问控制(CORS) 。 下文是简化了的理解。

我们已经知道,当 JS 跨源请求是简单请求时,会被限制读操作。 当 JS 跨源请求不是简单请求时,会被限制读写操作。 浏览器限制跨源请求,就是本着先禁止跨源的态度,然后让 服务器的 CORS 设置选择是否允许跨源。

@font-face引入跨源字体也遵守 CORS 设置。

另外已知,跨源请求是不安全的。所以在配置 CORS 的时候,必须清楚 CORS 设置的含义, 严格限制跨源请求的来源和方法!

检查跨源请求的源:浏览器在跨源请求中会强制加入请求头 Origin,该字段表示 跨源来自的第一方是什么,然后由服务器检查该 Origin 是否可信。服务器 可以在此抛弃请求并返回错误,或者返回响应和 CORS 设置。

说了这么多,CORS 设置到底有什么内容?CORS 设置,其实就是一些响应头部而已。 这些响应头部告诉浏览器该怎么处理跨源,分别是:

  • Access-Control-Allow-Origin 限制页面的源
  • Access-Control-Allow-Methods 限制请求方法
  • Access-Control-Allow-Headers 限制请求头部

不满足该设置的话,浏览器应该限制响应的读操作或者请求的读写操作。

如果想直接允许所有源的话,将Access-Control-Allow-Origin的值设为*就行了。

但是携带 Cookie 的 话,Access-Control-Allow-Origin无法设为*了,因为 Cookie 有用户凭证, 所以要阻挡未知的、不受信任的源。其实就是为了阻挡 CSRF 攻击。

JSONP

很多人很委屈:我不过是想通过 JS 获取数据,怎么就那么复杂呢?

既然 JS 跨源请求有如此多的限制,那么不用 JS 发送跨源请求,改成其他方式好不好呢? JSONP 就是这个思路。上面已经提到,资源载入的请求是不限制跨源的,所以, 构造一个<script src="获取数据 URL">标签向服务器发送 GET 请求就好啦! 但是,返回的数据是以脚本形式执行的,我们需要的是数据, 要怎么从一个执行脚本里读取数据呢?那就让它执行起来吧, 具体如下:

  1. 该 HTML 页面中已经定义了一个readString函数。
  2. 页面中的<script src="获取数据 URL">发送跨域请求。
  3. 服务器返回readString("数据"), 浏览器执行它,也就执行了先前定义的readString函数,该函数把数据解析使用。

至于服务器要怎么知道使用readString函数名呢?获取数据的 URL 告诉服务器需要什么数据, 以及约定使用什么函数名,也就是令 URL 携带参数callback=readString, 就能告知服务器具体的函数名。

所以,只需要在原先获取数据的 API 上进行简单改造,就可以利用 JSONP 进行跨源了。

实际上 JSONP 这个概念十分迷惑人,因为响应体完全可以不用 JSON。readString("数据")中 的「数据」,完全可以是任意有效的字符串。

JSONP 并不是什么高大上的概念,只是个跨域的技巧,仅此而已。

如果有什么要补充的话, 那就是 JSONP 只能使用 GET 请求。<script>标签引入脚本,发送的就是 GET 请求。 GET 请求中是无法携带大量数据的。如果需要跨源发送大量数据给服务器并且获得响应, 就不要使用 JSONP。

其他方式

上面说的,都是流行的正统方法。还有一些尚未成为主流但合理的方法,以及一些很「脏」的方法, 因为不是很关键,所以等以后有空再补充。

术语规范:第一方和第三方

规范使用这两个术语,对理解本文的内容至关重要。

我看见一些知名博主和技术团队的文章,把攻击者网站称为第三方网站。所以我们会看见作者使用这样的措辞: 用户访问了攻击者建立的第三方网站 B,该网站向被攻击网站 A 发动了 CSRF 攻击。那么,作者是 以网站开发者的身份来描述的,把自己的身份代入了被攻击网站 A,并视被攻击网站 A 为第一方, 则称攻击者网站 B 为第三方。

上文在讲跨源的时候,则是以浏览器的角度来看的。浏览器把直接访问的那个网站 (地址栏中显示的网站) 作为第一方称呼;而该网站的跨源请求发送给第三方。浏览器所使用的术语就是这样的。

那么,考虑一下本节开头的例子,实际情况是这样的:攻击者在网上任意位置留下一个链接, 该链接指向攻击者网站 B。用户被诱导点击了 B。用户的浏览器将打开网站 B, 此时地址栏中的域名是 B,意味着此时第一方是 B; 如果 B 发动了对 A 的 CSRF 攻击,那么 A 作为第三方被攻击。

所以为了防止概念混乱,本文统一从浏览器的角度来看待问题,即采用后一种描述方式。 其实浏览器的角度就是用户的角度。 我认为始终从浏览器的角度来看待 XSS 和 CSRF,有助于理解这些攻击形式。

是不是觉得这种方式不好理解,多次一举了呢? 之所以要严格划分,是为了方便寻找攻击的源头来自哪里。 如果一个网站开发者,自己的网站正遭受攻击, 连自己是作为第一方网站还是第三方网站被攻击都不清楚, 又要怎么防范攻击呢?而且,在下文提到第三方 cookie 和 cookie 标记 的时候,这里使用的术语第三方,也是按照浏览器的角度来理解的。

必须强调,关于第一方和第三方的划分,始终以浏览器页面的源 (地址栏中的源) 为准。

安全意识觉醒:XSS 和 CSRF

XSS:用户的浏览器在被攻击网站上 (作为第一方),运行了恶意代码。

CSRF:诱使用户的浏览器,在攻击者的恶意网站上 (或攻击者控制的肉鸡网站上), 向被攻击网站 (作为第三方) 以用户的身份发送请求 (攻击者伪造用户身份)。

这两种攻击形式,都发生在用户的浏览器,不是在攻击者的浏览器上。

在有了第一方和第三方的划分后,可以清楚地知道,XSS 和 CSRF 的来源是不一样的。

XSS

注意,XSS 的恶意代码,是在第一方网站上运行的。 XSS 一词中,「跨站」的意思并不准确,让人误以为恶意代码必须从第三方网站引入。实际上, 攻击者可以直接嵌入恶意代码在第一方网站的网页中,比如直接嵌入<script>一段长长的恶意代码</script>; 也可以从第三方引入使用<script src="链接">。之所以要跨站引入恶意代码,是因为相比直接嵌入恶意代码, 更加容易,效率更高。所以,后一种方式更为流行,这也是 为什么主流称呼其为跨站脚本攻击。无论是哪种方式,从最后的结果来看, 都是受害用户浏览器在第一方网站上执行这段恶意代码。而且,不仅是「跨站」一词不准确,「脚本」一词 也不准确。攻击者可以只嵌入<style>,玩笑性质地破坏网页样式。但终究是「跨站脚本」更加流行,案例 最多。

XSS 的恶意代码能做到什么事情呢?任意 JS 代码都有可能。包括窃取 cookie 中的身份凭证向攻击者发送、以用户身份 发送请求、篡改页面内容,等等。因为是任意可能的 JS 代码,所以有无限可能。

关于 XSS,应该换一个说法:一些文本被错误地、故意地解析成 HTML 代码。在 HTML 页面中展示的文本,理应不可执行。 但是 XSS 漏洞被利用,变成了可解析、可执行的代码。这些恶意代码,包装成参数 传递给前端或后端。 最简单的例子,恶意代码被当成参数,直接以innerHTML的方式插入到 HTML 页面中。

XSS 的种类

网上大部分关于 XSS 的文章,无外乎告诉你 XSS 有三种类型:反射型、存储型、DOM型。通常这段内容 是复制粘贴的重灾区,也许你早以看腻了。但这次我们来思考些不一样的。

其实归根结底,XSS 攻击的本质是前端或后端盲目相信外部参数 (入参)。无论 XSS 有多少种攻击形式,都是通过 非法参数将恶意代码注入第一方网页上的。每个开发者都应该知道,程序不应该相信任何入参是合法的。 哪怕是一个未公开的 API,都有可能被攻击者 发现,并恶意传参。本着这个思路,再来看 XSS 攻击类型都简单多了。

反射型XSS:攻击者在第一方 URL 上拼接恶意参数,并诱使受害者点击。受害者的浏览器访问带有恶意参数的 URL, 将向服务器后端请求 HTML,同时携带了这个恶意的 URL 参数。 服务器没有校验这个恶意参数,而是使用它,并返回了包含恶意逻辑的页面。 这在传统的 Web 架构上会发生,比如 PHP 等在后端渲染模板的方式。

DOM型XSS:对于现代出现的 SPA 页面来说,前端页面直接处理 URL 参数,不需要经过后端。 如果前端没有校验这个恶意参数,而是使用它,通过 DOM API 加入到页面, 同样会导致页面出现恶意代码。

存储型XSS:恶意代码存储在了服务器后端的数据库中。后端又将其渲染到了 HTML 页面中。 比如 XSS 事故多发区:发表评论时,该评论中有恶意的<script>语句,被存储在数据库中, 当页面需要向所有人显示评论的时候,又被渲染在前端页面上。此时,前端页面没有转义 <script>语句,而是直接插入到了标签中。最后导致<script>被执行。

通常,反射型XSS 和 DOM型XSS 被认为是非持久型,存储型XSS 被认为是持久型。持久 的意思就是数据存储在数据库中的意思。非持久型的恶意参数一般是不会储存在数据库 中的,只可能出现在日志文件中。

你可能会注意到,我在描述 XSS 攻击形式的时候,列举的内容是片面的, 没有覆盖到所有 XSS 攻击的情况。 因为,我不认为 XSS 攻击必须划分成这三类形式。 别看 XSS 千变万化,我们必须认清 XSS 攻击的核心概念: 任何 XSS 攻击形式都是因为攻击者传入了恶意参数,前端或后端没有校验的情况下使用了它。 这才是 XSS 攻击的实质。至于为什么要介绍这三种类型,是为了让我们了解到 XSS 借助入参无孔不入。

实际上,XSS 的概念反而相对较小。因为,「攻击者传入了恶意参数,前端或后端没有校验的情况下使用了它」 还包括这种情况:请求中的数据让服务器后端执行了恶意逻辑。比如,SQL 注入和恶意路径。 SQL 注入:后端直接将恶意参数和 SQL 语句拼接在一起,执行了恶意的 SQL 语句,破坏了数据库。 恶意路径:后端直接将恶意参数和路径拼接在一起,然后在一个非法的路径上操作文件。

所以说,XSS 才是此类攻击的一个子集, 因为 XSS 是在第一方页面嵌入恶意代码,并让受害者的浏览器执行, 完全不涉及后端执行恶意逻辑的情况。 虽然 反射型XSS 和 存储型XSS 要经过后端处理,但后端完全没执行任何恶意逻辑, 而是返回有恶意逻辑的 HTML 页面而已,这和 SQL 注入攻击等有差异。

另外,我使用了「恶意」一词。但请注意存在特例, 那就是用户无意中传入错误参数、或者代码自身存在缺陷 传入了非法参数,从而导致页面出现错误逻辑。这再次印证了需要校验每个入参的重要性。

所以,防范 XSS 攻击的方式就是不相信任何入参是合法的,需要校验; 这个参数有可能不是用户传入的,而是攻击者。 前端页面甚至不能相信来自后端数据库中的数据是合法的。

跳出 XSS 思维

在此拓展一下 XSS 的思路。 认清 XSS 的本质后,我们再来考虑一个更大范围的参数校验情况。 那就是设计函数库要不要校验参数。 函数库作为第三方库被调用,这是信任机制,取决于函数库有多信任调用者。

因为函数接口校验参数是有性能代价的,如果每一层函数调用栈都要检验参数,就显得臃肿多余。 所以,从实践的角度讲,考虑这个函数库是否公开。如果该函数库是公开的, 比如一个开源项目,或者为一个公司集团内部设计的函数库,那就有义务校验任何入参。 任何对外 API,需要考虑到恶意调用者的存在,或者无恶意的调用者因为失误而传入错误参数。 有一句话讲得好:最好是把所有用户 (API 调用者) 都当成是别有用心的傻子。

更多注入形式

XSS 通过参数注入恶意代码到第一方网站上;那么想往第一方网站上注入恶意代码,只有这条途径吗? 我可没这么讲过,因为事实上能做到这一点的,可不是只有 XSS。比如,CDN 上托管的资源被篡改。 当第一方网站将一些脚本等资源托管在 CDN 上时,CDN 的安全也将是一个大问题。 这意味着 CDN 有可能存在安全漏洞,从而导致脚本资源被篡改,第一方网站从 CDN 上引入了恶意代码。 跨站引入恶意代码,看起来像是 XSS,对吧?有些人的确会把这种情况称之为 XSS,但要注意,这种 攻击方式的原理和上文说到的不一致。

这里可以用到子资源完整性 (SRI) 让浏览器校验外部资源是否被篡改。

另外,像是中间人攻击、DNS 劫持等等,都可以直接篡改第一方页面。所以,不要以为没有 XSS 漏洞就安全了。

初见 CSRF

相较于 XSS 这种在第一方网站上大摇大摆的攻击方式来说,CSRF 显得更加隐蔽。 CSRF 看上去就是用户自己的行为, 我们看不见有恶意代码在执行。实际上,CSRF 确实不需要恶意代码。 攻击者只需要建立一个看上去很正常的 恶意网站,用户访问这个攻击者网站时,后台悄悄地发送请求到第三方网站上, 该请求使用用户在第三方网站的 cookie。CSRF 就是要使用用户在第三方网站上的 cookie。 CSRF 不需要知道该 cookie 存储的内容,只需要让请求携带 cookie 而已。

通常,大家在描述 CSRF 攻击的时候,都会举一个例子:攻击者冒充用户身份发起转账请求。 这其实说明,CSRF 攻击看重的是请求的写操作而不是读操作。

另外,我看见有「从本域发起的 CSRF」的说法。但是,从本域发起的冒充用户的请求,不是 CSRF! CSRF 就是从第一方向第三方发起的跨源请求。并非「冒充用户身份」的请求就是 CSRF。 因为上面讲 XSS 的时候,也说过 XSS 是可以冒充用户请求的。 第一方页面向第一方服务器发送的冒充用户的请求,就是通过 XSS 漏洞实现的。

阻止 CSRF 的思路:

  1. 拦截跨源请求
  2. 允许跨源请求但禁止携带 cookie
  3. 增加检验用户身份的机制:CSRF token

关于第 1 点,我们已经知道,服务器方检验请求的来源,以及合理的 CORS 设置,能规避 一些 CSRF 攻击。接下来我们再来琢磨第 2 点。第 2 点需要我们先了解 cookie 的一些安全标记。

前文阐述了跨源请求,但没有过多涉及 cookie 的内容。本节将展开讲跨域 cookie。请注意, 本文在讲 cookie 的时候,用的是跨域一词而不是跨源。

我们一直在把被攻击网站称为 A,现在考虑 A 作为第三方。此时第一方页面的域和 A 的域不同,A 的 cookie 相对于页面, 就是第三方 cookie。页面发送给 A 的请求就是跨域请求,A 的 cookie 作为第三方 cookie 被携带。至于第一方页面的 JS,是无法读取第三方 cookie 的,但是可以 向第三方域写入 cookie。2022-01-05 更新:经读者提醒,这里是我的一段迷惑发言。实际上 JS 是不能跨域写 cookie 的。 我没明白当初写下这段话时,我在想些什么😂)

跨源请求受同源策略影响。而 cookie 使用了另一套类似的机制。 同源策略限制了跨源请求的读操作 (抛弃响应),浏览器限制 cookie 的方式则是, 允许发送请求,但同时剥离 cookie (不发送 cookie)。 浏览器允许 JS 跨域写 cookie,但禁止跨域读 cookie。2022-01-05 更新:此处有误。JS 不能跨域写 cookie!) 如果说同源策略是先禁止,再用 CORS 放宽限制的话, cookie 安全限制则比较宽松,需要服务器后端主动设置限制策略。

Secure 和 HttpOnly 标记

Secure:限制携带 cookie 的跨域请求必须使用安全的 HTTPS 协议。 由于 cookie 跨域只限制域名,加上 Secure 标记限制 HTTPS 加密,防止中间人直接获得未加密的 cookie。

HttpOnly:禁止第一方 JS 读写 cookie,cookie 只能用于 HTTP 请求中。 上面说到 XSS 的原理,是恶意代码以第一方的身份在执行的。 HttpOnly 标记一定程度上限制了 XSS 获取 cookie 内容的可能性。 但代价是,非恶意的 JS 代码也不能获取 cookie 内容。 还需要注意,这并不能阻止 XSS 使用 cookie。因为 XSS 向第一方 发送恶意请求依旧可以携带 cookie,只是 XSS 恶意代码不知道 cookie 其中的内容罢了。 简单的开启 HttpOnly,不是根治 XSS 的良方。

所以以上两个标记,算不上是对 XSS 和 CSRF 的决战用兵器。 接下来,看一个关键的标记:SameSite。

SameSite 标记

SameSite 标记现在有三种值:None,Lax,Strict。如果 cookie 没有设置, 将使用默认的 None。

SameSite=None,完全没有限制,所有向第三方发送的跨域请求都可以携带 cookie。

SameSite=Lax,完全限制了向第三方发送的跨域请求携带 cookie。JS 向第三方的请求, 标签载入第三方资源的 GET 请求,还是表单 POST 第三方请求,都不再携带 cookie。 只有当用户打开链接,也就是导航到链接指向的第三方网站时, 此时链接的域成了页面的域,才可以携带该链接的域的 cookie。

SameSite=Strict,是在 Lax 的基础上,限制打开链接时向该域发送 cookie, 也就是上面说的那种请况,由允许携带 cookie 变为不携带 cookie。

即使允许跨域请求,SameSite 剥离了请求中的 cookie,所以,CSRF 想通过跨域请求 冒充用户的企图被瓦解了。

此时,各大主流浏览器均支持 SameSite=Lax,但默认设置依旧是 SameSite=None。 想要让 Firefox 默认使用 Lax,需要在about:config中开启: 将network.cookie.sameSite.laxByDefault设为 true。chrome 决定从 80 版本 默认开启 SameSite=Lax,但是在随后的新版本中又改回成了 SameSite=None。 现在很多网站架构尚未支持 SameSite=Lax,强行让默认的 None 变为 Lax, 会破坏这些网站的页面逻辑。所以 chrome 决定暂时撤回到 None。

所以,如果浏览器大力推广 SameSite=Lax,将有效缓解 CSRF。但是,服务器后端 完全可以主动将 cookie 的 SameSite 标记设为 None,只不过是增加一个字段而已。 意思是浏览器在默认请况下禁止发送第三方 cookie,但不限制浏览器后端主动 允许它。再考虑到现在很多网站架构离不开第三方 cookie,很有可能 SameSite 得不到有效推广。

如果网站主动支持 SameSite=Lax,那就再好不过了。

跨域读取 cookie 的方法也是热门话题。既然已经知道 JS 是不能跨域读取 cookie 的, 那就换个思路,向拥有 cookie 访问权限的网站进行询问。想知道第三方 A 的 cookie 的内容, 就向网站 A 发送一个询问 cookie 内容的跨域请求,该请求会携带 A 的 cookie, A 能解析其中的 cookie。第三方 A 实现这个答复 cookie 内容的 API 即可。

由于该跨域请求,本身就是一个跨源请求,同样要受到同源策略的限制。所以,想要 浏览器得到响应,这部分内容就是 允许跨源请求的方法

但是,为了 cookie 的安全着想,真的有必要允许跨域访问吗?如果有必要的话, 这就和跨源请求该考虑的安全问题一样,不应该允许响应不受信任的域名。 很遗憾,我看到网上一些介绍跨域访问 cookie 的文章中,没有强调安全问题。 相反,他们为泄漏 cookie 开了一个口子。很多人只满足于一个解决方案而已。

所以,浏览器禁止跨域访问 cookie,是为了安全。允许跨域访问 cookie 的 API, 必须限制受信任的来源。

SPA 的出现改变了传统的方式,身份凭证可以不存储在 cookie 中。 SPA 的 JS 通过 API 获取 token,然后将 token 存储在 localStorage 中。 SPA 的 token 不通过 cookie 发送给服务器后端,可以选择 将 token 放在自定义的请求头部,或是请求体中。然后服务器后端相应地检测 这个位置。

再见 CSRF

说穿了,CSRF 唯一能利用的只是用户的 cookie 而已。CSRF 攻击者并不需要 得到用户更多的身份信息,甚至都不知道 cookie 中的内容是什么,就 可以进行 CSRF 攻击了。上面已经说过,cookie 的 SameSite 标记,可以直接 击落跨源请求中的 cookie。但是对于很多网站架构来说,却又实实在在需要 第三方 cookie。结果 SameSite 标记就派不上用场了。

那么,可以考虑用一个 CSRF 攻击者无法利用的 token,来阻止攻击。

CSRF token

服务器后端除了为每个用户颁发用户凭证外,另外生成一个 token 给用户。 只有同时持有用户凭证和该 token,才能确认是真正的用户。 这就是 CSRF token,通常是攻击者无法预测的随机字符串。 CSRF token 和用户凭证是绑定的,当随机的 CSRF token 和用户凭证 被服务器判定不存在联系时,那就是有人在伪造用户身份。 用户凭证存在于 cookie 中,但额外的 token 不通过 cookie 传递,而是在 HTML 页面中。

在只有 cookie 包含用户凭证的情况下,CSRF 就可以利用 cookie 冒充用户。 但 CSRF 无法利用额外的 CSRF token。为什么呢?再想想 CSRF 的攻击方式,用户 在恶意网站上,被迫向第三方网站 A 发送请求,虽然该请求携带用户 cookie, 但却没有额外的 token,所以被服务器认为该请求是非法的。

为什么 CSRF 请求无法携带 CSRF token 呢?CSRF token,只能从网站 A 以第一方 的方式获取。也就是 CSRF token 只存在于第一方的 HTML 页面中。 而 CSRF 攻击,发生在第一方的恶意网站上,此时网站 A 作为第三方,根本就 没有网站 A 的 HTML 页面,所以也就无法获取 CSRF token。

将 CSRF token 加入页面

上面说到了 CSRF token 原理,再来考虑如何实现。既然 CSRF token 仅限于第一方的 HTML 页面中, 那也就意味着,该页面所有的请求发送都需要携带 CSRF token。如果是 GET 请求, 那就把 CSRF token 拼接到 URL 参数中;如果是表单 POST 请求,则以 POST 参数放在表单中。 这些步骤,可以发生在后端在渲染 HTML 页面的时候;或者是前端页面中,通过 JS 操作 DOM 添加。

OAuth 中的 state 参数

在理解了 XSRF 和 XSRF token 后,甚至可以用相同的思路理解 OAuth 中的 state 参数。

state 参数是为了安全而设计的。为了理解 state 参数的作用,我们先简化 OAuth 登录的思路。

简化理解 OAuth 登录流程

网上很多介绍 OAuth 的文章,会告诫我们一句话:OAuth 的作用是授权,不是认证。 什么意思呢?如果一个网站 A 支持 OAuth 登录账号,又意味着什么呢?

其实所谓的 OAuth 登录,是网站开发者们在把 OAuth 的授权当作认证使用了, 偏离了 OAuth 的本意 (但不算错)。

有个网站 A,支持从 OAuth 服务器登录账号。网站 A 认为:

  1. 所有 OAuth 服务器上的账号,都被网站 A 认为是自己的用户。(这点很重要,容易被忽视)
  2. OAuth 服务器认证用户身份 (用户输入用户名和密码)。然后询问是否授权网站 A。得到了用户的同意。
  3. 网站 A 被用户授权后,也就让网站 A 验证了用户身份 (因为用户在 OAuth 服务器上被成功认证了。 不然无法授权)

比如本站双猫 CC 支持 GitHub 账号 OAuth 登录发表评论,那就是说, 所有 GitHub 账户都是我双猫 CC 的用户。只要你能授权我获取你的用户名 和头像信息 (用户授予网站权限),就能证明你是账号持有者。 至于双猫 CC 授予用户评论权限 (网站授予用户权限),这和 OAuth 无关。

第 2 点的认证用户,归根结底是为了授权。授权才是 OAuth 的目的。

当然,第 2 点是可以在 OAuth 登录流程前发生的。如果你提前登录 GitHub 账号, 已经持有 GitHub 的身份凭证 (cookie),那么在 OAuth 登录流程中就免去输入 用户名和密码,直接通过 OAuth 服务器的认证。

一句话:如果你 (OAuth 账号持有者) 能授予本站权限,就能验证你是 OAuth 账号持有者, 也就验证了你是本站用户的身份。

上面介绍的 OAuth 登录流程,还没有涉及 state 参数呢。 别急,我们再来看第 3 点中授权这一步。 用户要怎么通过 OAuth 服务器要授权网站 A 呢?当用户在 OAuth 服务器上通过认证后,OAuth 服务器是 浏览器中的第一方。OAuth 服务器会使用 callback 回调地址,并且携带授权码,让用户的浏览器跳转回网站 A。 用户的浏览器访问回调地址,是访问网站 A (第一方),同时通过 URL 参数把授权码告诉了网站 A。 这一步需要用户的浏览器参与,浏览器是 OAuth 服务器和网站 A 的桥梁,亲自把授权码告知网站 A。 由于有回调跳转,此时地址栏中显示的就是网站 A 的地址。 这是一个 GET 请求。 至于 OAuth 服务器,才不会亲自告诉网站 A 授权码呢。

网站 A 的后端拿到了授权码,就可以自己向 OAuth 服务器申请 Access Token, 被 OAuth 服务器授权。网站 A 再给用户颁发一个网站 A 的身份凭证。之后用户就有网站 A 的用户身份。

咦?怎么还是没看到 state 参数?没有看错。state 参数是为了提升安全而设计的。如果没有攻击者, state 参数就派不上用处了。但是现实很残酷,互联网上到处都是攻击者。现在,攻击者将要对 OAuth 登录 发动 CSRF 攻击了。先让我们看看 CSRF 攻击者要从 OAuth 登录流程哪个环节下手。

对 OAuth 的 CSRF 攻击:第一种

整个 OAuth 登录流程中,最不安全的就是那个 GET 请求了。哪个?用户的浏览器,向网站 A 发送授权码 的那一步。这一步甚至直接把授权码暴露在地址栏中,虽然这个跳转时间很短,但是依旧有风险, 所以授权码的有效时间也设计得很短。不过这一点和 CSRF 无关。本文不关心攻击者如何窃取用户的 授权码,因为本文讲的是 CSRF。本文关心的是 CSRF 攻击者让用户使用攻击者的授权码!什么? 用户使用攻击者的授权码?这又有什么用呢?也许我们可以构思第一种情况 (之后再讲第二种): 用户使用攻击者的授权码,登录了攻击者的账号,却误以为登录了自己的账号。 然后上传了一张私密照片……嗯。

CSRF 是怎么做到的呢?因为这一步是 GET 请求,所以极易在网页中悄悄发送。 上文说到跨源的时候,已经讲过, 浏览器是不检查<image>等标签的跨源 GET 请求的。所以攻击者在恶意网站 B 上,放入这个 GET 请求, URL 参数包含了攻击者的授权码。然后诱导用户访问恶意网站 B。此时恶意网站 B 以第一方的身份, 向网站 A 发送 GET 请求,让用户浏览器登录了攻击者在网站 A 上的账号。这就是 CSRF 呀。 之后某天用户打开网站 A,却已经登录了,账号是攻击者的账号。

所以 OAuth 登录的错误到底在哪里呢?因为这个 GET 请求,就不是一个幂等请求。这个 GET 请求, 实现了账户登录。非幂等的 GET 请求是极易遭受攻击的。但是 OAuth 也很为难,因为回调跳转就 是要用 GET 请求打开 URL 的。所以,加入了一个 state 参数防止 CSRF。没错,此时 state 参数的 作用相当于 CSRF token。

state 参数的实现

就如同 CSRF token 的实现一样,后端必须先赋予未登录用户一个 session,该 session 是和随机生成 的 state 参数关联的。只要请求持有的 session 和 state 满足关联,就认定是合法用户身份;否则 当作 CSRF 攻击处理。

对 OAuth 的 CSRF 攻击:第二种

这种攻击方式,比上一种更加隐蔽,而且危险性越高,因为攻击者甚至可以窃取用户账号!

用户已经在网站 A 上持有一个账号了。现在考虑绑定另一个 OAuth 账号。服务器后端是怎么实现绑定 账号的呢?那就是调起另一个 OAuth 服务器的登录。如果网站 A 能被授权,说明用户账号和该 OAuth 服务器存在关联,也就是同一个人。所以服务器认定,该账户今后可以从此 OAuth 账号登录了。

问题出在哪呢?就是这个另外的 OAuth 登录流程,其中的 GET 请求依旧可以被 CSRF 利用。 这个 GET 请求也不是幂等的,因为它不仅实现了 OAuth 登录,而且还实现了绑定账号的操作! 仿造上一种攻击方式,攻击者又悄悄地让受害用户登录了自己的账号,此时,还将攻击者账号 和受害者账号绑定在一起了。至此,攻击者就可以登录受害者的账号了。

知乎上的 ThoughtWorks 中国 有个详尽的回答,就是关于这种攻击方式的。

深究 OAuth 流程设计

我知道还有很多人还一直在纠结为什么 OAuth 流程要这些步骤,觉得 OAuth 流程设计多此一举。 因为我们已经从上文里获取到足够的基础知识了,所以我认为展开理解这部分内容也 不难。我们从三个问题分析。

OAuth 服务器不返回授权码,直接返回 Access Token 给浏览器,行不行?

不行。我知道有些人提这个问题,是认为浏览器获得 Access Token 再发送给网站 A,也能实现整个流程。 前面已经解释过,OAuth 的真正作用是授权,不是认证。如果直接返回 Access Token,相当于 让用户的浏览器也有可能持有权限。OAuth 严格限制授权给谁,这是它的本职工作。所以,不能泄漏 Access Token 给除网站 A 之外的任何组织。还有,Access Token 有效时间更长,这也就意味 着使用有效时间更短的授权码更加安全。而且用户自己持有 Access Token,风险更高。

OAuth 服务器自己直接发送 Access Token 给网站 A,行不行?

不行。我知道有些人提这个问题,是认为浏览器没必要参与发送授权码, 直接让 OAuth 服务器和网站 A 通信不就行了?但请注意, 网站 A 和浏览器可以处在某个集团的内网中,网站 A 不暴露任何回调地址给外部网络。 所以,一个处于外网环境中的 OAuth 服务器,无法直接传递 Access Token 给内网环境中的网站 A。 而是通过跳转告诉用户浏览器授权码;因为用户浏览器和网站 A 同处一个网络,所以用户浏览器 能告诉网站 A 授权码。这样就实现了:内网环境中的网站 A 支持外网环境的 OAuth 登录。

既然我们已经认识到,Access Token 不可外泄的重要性。那么再看下一个问题。

用户可以自己拿授权码,换取 Access Token 吗?

做不到。用户和攻击者都做不到。上面已经说到,Access Token 只能给被授权的组织使用。 所以 OAuth 流程设计者规定,使用授权码需要同时传递 client secret, 这个 secret 是网站 A 在 OAuth 服务器上提前设置好的。 除了网站 A,没有其他组织和个体持有 client secret, 所以它们无法使用授权码换取 Access Token。这样一来,就限制了只能授权给网站 A。 这也就顺便解释了为什么网站 A 在配置 OAuth 服务的时候,需要创建 client secret。

总结

回顾全文,我们先从跨源开始,建立了知识基础;在理解 XSS 和 CSRF 前,先了解第一方和第三方的概念。 在介绍 XSS 后,CSRF 被划分为两节,前一节是为了和 XSS 作比较,后一节是 CSRF 的应对方式。 其中穿插的一节 cookie 安全知识。最后,是 OAuth 的安全相关内容。

需要提醒的是,不要只满足于寻找某个问题的解决方案。我们不要为了解决问题而创造更多的安全问题。

CC BY-NC-SA 4.0

© 2020-2024 rydesun