深入CORS:历史,工作原理和最好的例子
学习同源策略和CORS(Cross-Origin-Resource-Sharing)(跨域资源共享)的历史的演变,深入理解CORS和不同类型的跨域访问策略以及一些最好的例子。
文章目录
- 你的浏览器控制台报错了
- 起点:第一个子资源标签
- 域与跨域
- 跨域请求的危害
- 同源策略
- 进入CORS
- 跨域写
- 预请求
- 跨域读
- 如何使用好CORS
- 一些好例子
- 所有请求都允许
- Keeping it in the family
- NULL域
- 跳过Cookies,如果你能的话
- 附加阅读
- 后记
你的浏览器控制台报错了
我确定你在浏览器的控㓡台见过上面的报错或者它们的变体。如果你没有见过,不用担心,你总会遇到的。对于所有开发人员来说,遇到CORS错误是常事。
在开发过程中遇到些种问题可能是烦人的,但实际上,在一个配置异常的web服务器上,网络上的攻击者或者推动网络标准的组织来说,CORS是一种非常有用的机制。
首先,让我们回到起点。
起点:第一个子资源标签
子资源标签是一个请求被嵌入到文档或者一个请求在上下文中被执行的HTML标签。在1993年,第一个子资源标签 <img>
出现了。为了兼容 <img>
标签,网络页面变得更漂亮了,但也更复杂了。
所以,如果你的浏览器想要渲染一个带有 <img>
标签的网页,它就必须从域中请求子资源。当浏览器请求的子资源与域在协议,主机名和端口上任何一个不同时,这就是一个跨域请求。
域与跨域
域被从三个方向定义:协议,完整的主机名和端口。例如:http://example.com
和 https://example.com
是不同域的。第一个使用 http
协议,而第二个使用 https
协议。而且默认的 http
协议使用80端口,而 https
协议使用443端口。所以在这个例子中,这两个域因为协议和端口不同而不周域,尽管它们的主机名是一样的。
同理,你知道如果三个条件中其中有一个不同,那域就是不同的。
下面是我们总结与 https://blog.example.com/posts/foo.html
域同源或者跨域结果的例子:
URL | 结果 | 原因 |
---|---|---|
https://blog.example.com/posts/bar.html | 同源 | 只有路径不同 |
https://blog.example.com/contact.html | 同源 | 只有路径不同 |
http://blog.example.com/posts/bar.html | 不同源 | 协议不同 |
https://blog.example.com:8080/posts/bar.html | 不同源 | 端口不同( https:// 默认使用443端口) |
https://example.com/posts/bar.html | 不同源 | 主机名不同 |
其中一个跨域请求例子:一个来自 http://example.com/posts/bar.html
页面想请求从 https://example.com
来的子资源。
跨域请求的危害
现在我们知道了什么是同源和跨域,让我们看看会发生什么大事。
当我们介绍出现了第一个子资源标签 <img>
的时候,我们就已经打开了闸门,之后我们有了 <script>
,<frame>
,<video>
,<audio>
,<iframe>
,<link>
,<form>
等等更多的子资源标签。这些子资源标签都可以发出同源或者跨域请求。
我们想像一个CORS不存在而且所有跨域请求都被允许的世界。
想像这样一个场景:我从 evil.com
得到一个带 <script>
标签的页面,从表面上看,这是一个简单的页面,能显示一些有用的信息。但是在 <script>
标签里面有我精心设计的代码,会发送一个精心设计的 DELETE /account
请求到银行的服务器。你只要加载一次这个页面,JavaScript脚本就会被执行,异步请求就会调用银行的API。
惊喜不惊喜?你只要在网页上浏览一些信息,你就能收到成功删除银行账号的邮件。事实上我猜它能向银行做任何事情。
为了让我邪恶的 <script>
标签能工作,在请求中你的浏览器还必须带从银行网站得到的凭证(cookies)。这是为什么银行服务器能认识你并知道删除哪个账号的原因。
现在让我们看一个不那么邪恶的例子。
我想查看一个在Awesome Corp工作的朋友,他工作的内网是 intra.awesome-corp.com
。在我的网站 dangerous.com
我有一个 <img src="https://intra.awesome-corp.com/avatars/john-doe.png">
标签。
未能与内网 intra.awesome-corp.com
建立有效会话的用户,头像将不会渲染,会出现一个错误。但是,当你登录内网,访问过一次我的网站,我就知道你建立了有效会话。
这意味着我能得到你的一些信息。对我来说直接攻击可能比较困难,但是知道你登录Awesome Corp仍然是一个潜在的攻击媒介。
这两个过于简单的例子显示出的威胁说明了同源策略和CORS的必要。跨域请求的危险多种多样。一些可以缓解,另一些则是不能缓解的-它建立在网络的本质上。因为CORS,大量的攻击媒介被压缩。
在了解CORS之前,我们要先了解一下同源策略。
同源策略
同源策略可以通过阻止不同源的资源加载来预防跨源攻击。但是这个策略还是允许一些标签如 <img>
嵌入不同源的资源的。
同源策略每一次出现在1995年的2.02版本的网景浏览器(Netscape Navigator)上。源头是为了保护跨源访问DOM。
虽然同源策略没有明确的标准,但是所有现代浏览器都以某种形式实现了它,最后IETF在RFC6454中定义了同源策略的标准。
同源策略被定义成了下面的规则集:
标签 | 跨域策略 | 注意 |
---|---|---|
<iframe> | 允许嵌入 | 取决于 X-Frame-Options |
<link> | 允许嵌入 | 属性 Content-Type 可能需要 |
允许写 |
同源策略解决了很多问题,但是也有点严格了。从单页应用到重媒体网站,同源策略并没有留下足够的余地去调整这些规则。
CORS出生的目标就是调整同源策略以适应不同的跨域访问。
进入CORS
现在我们知道了源的定义,跨域请求的缺点和浏览器实现的同源策略。
是时候了解CORS(跨域资源分享)了。CORS是一种允许控制通过网络访问网页上的子资源的机制。这种机制定义了三种子资源访问策略:
- 跨域写
- 跨域嵌入
- 跨域读
在我们解释每一种策略之前,需要意识到非常重要的一点是:浏览器允许某种类型的跨域请求,并不代表服务器会接受这类请求。
跨域写 代表链接,重定向和表单提交。当你的浏览器开启CORS时,这些都是被允许的。而且还有一种叫预请求的机制对跨域写进行调整。所以默认允许进行跨域写也并不代表它能成功。我们会在稍后讨论它。
跨域嵌入代表子资源加载,如:<script>
,<link>
,<img>
,<video>
,<audio>
,<object>
,<embed>
,<iframe>
等等。这些默认都被允许。<iframe>
比较特殊——它的目的是嵌入另外的页面,是否允许嵌入被 X-Frame-options
请求头控制。
当是其它标签时,它们很自然的会触发跨域请求。这是为什么CORS要区分跨域读和跨域嵌入的原因。
跨域读代表通过调用AJAX或者 fetch
函数加载子资源。它们在你的浏览器中默认是被禁止的。这是在页面中嵌入子资源的一个解决方法。但是这种方法在现代浏览器中被另一个策略控制。
如果你的浏览器是最新的,所有的这些策略应该已经都实现了。
跨域写
跨域写可能是问题的。让我们通过例子写实际看看CORS。
首先,我们设置一个简单的Crystal HTTP服务器(使用 Kemal)(我自己使用express服务实现了一个相同的):
require "kemal"
port = ENV["PORT"].to_i || 4000
get "/" do
"Hello world!"
end
get "/greet" do
"Hey!"
end
post "/greet" do |env|
name = env.params.json["name"].as(String)
"Hello, #{name}!"
end
Kemal.config.port = port
Kemal.run
这一个简单的请求,路径是 /greet
,有一个name参数,返回一个 Hello #{name}!
字符串。为了运行这个服务器,你可以用下面的命令:
$ crystal run server.cr
它将会启动服务到 localhost:4000
。如果现在我们使用浏览器访问这个路径,会得到一个简单的 “Hello World”页面。
现在,我们的服务在运行中,我们可以向服务器的 localhost:4000
发送 POST /greet
请求。在浏览器的控制台,我们可以通过 fetch
下面的代码发送请求:
fetch(
'http://localhost:4000/greet',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ilija'})
}
).then(resp => resp.text()).then(console.log)
发送请求之后 ,我们就能在控制台看到服务器返回的响应了。
这是一个 ‘POST’ 请求,但是并没有跨域。我们是从浏览器已经渲染的 http://localhost:4000
页面发送的请求,所以是同源。
现在让我们来试试跨域版的相同请求。我们打开 https://google.com
(我就打开百度了),在控制台中发送相同的请求。
现在我们得到了最著名的CORS错误。仅管我们的服务器可以响应这个请求,但是我们的浏览器还是从我们自己手里保护了我们。这大概告诉我们一个我们已经打开的网站想要去修改另一个网站。(这就是为什么叫跨域写的原因!)
在第一个例子里面,我们发送请求 http://localhost:4000/greet
的tab是已经渲染的 http://localhost:4000
,我们的浏览器注意到这个请求并让它通过了,因为这看起来就像是我们的网站在请求我们的服务器一样(这是正常的)。但是在第二个例子里面我们的网站 https://google.com
想去写另外一个网站 http://localhost:4000
,所以我们的浏览发现了这个请求并阻止了它。
预请求
如果我们深入控制台,在网络这个tab里面,我们会发现实际上发送了两个请求而不是一个。
而且,我们注意到第一个请求是 OPTIONS
请求。第二个才是 ‘POST’ 请求。
如果我们观察一下就会发现。这个 OPTIONS
请求发送在 POST
请求之前。
而且我们还发现,尽管 OPTIONS
请求的响应状态是200,但是在请求列表里面还是和 POST
请求一样的红色,为什么?
因为这是浏览器发的预请求。一个预请求是对CORS认为的复杂请求进行预先请求的行为。那复杂请求的标准是什么:
- 除了
GET
,POST
,HEAD
之外的请求 - 请求包含除了
Accept
,Accept-Language
,Content-Language
之外的请求头 - 请求头
Content-Type
的值是除了application/x-www-form-urlencoded
,multipart/form-data
,text/plain
之外的值
上面的例子,虽然我们是一个 POST
请求,但是因为请求头 Content-Type: application/json
的原因浏览器认为这是一个复杂请求。
如果我们把服务改成处理 text/plain
类型的内容,就可以跳过预请求了。
fetch(
'http://localhost:4000/greet',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ilija'})
}
).then(resp => resp.text()).then(console.log)
现在,我们请求的请求头也修改为 Content-Type: text.plain
,body也对应修改:
options "/greet" do |env|
# Allow `POST /greet`...
env.response.headers["Access-Control-Allow-Methods"] = "POST"
# ...with `Content-type` header in the request...
env.response.headers["Access-Control-Allow-Headers"] = "Content-type"
# ...from https://www.google.com origin.
env.response.headers["Access-Control-Allow-Origin"] = "https://www.google.com"
end
现在预请求就不会发送了,浏览器的CORS策略还是会继续阻止这个请求。
但是因为我们精心设计了一个简单请求,实际上我们的浏览器并不会阻止这个请求。
简单总结:我们的服务器没有配置接受 text/plain
跨域请求的配置,而且也没有任何保护措施,,浏览器也无能为力。但是浏览器做了一个很对的事,就是不让我们打开的页面或者tab得到请求的响应。因此这个例子中,CORS阻止的不是请求,而是响应。
我们浏览器明确的认为这个请求是一个跨域请求,因为尽管这个请求是一个 POST
请求,而且请求头 Content-Type
的值看起来与 GET
请求相似。但是跨域读默认就是被禁止的,所以我们看到控制台网络tab页下的请求的响应被阻止了。
上面例子中解决预请求的做法不是常规的。实际上如果你希望你的服务器可以优雅的解决预请求,应该在服务器实现 OPTIONS
请求的响应,返回正确的响应头。
当服务器实现 OPTIONS
请求之后,你需要知道浏览器的预请求会在响应里寻找三个特殊的响应头:
Access-Control-Allow-Methods
它代表响应的URL支持哪些方法可以跨域Access-Control-Allow-Headers
它代表响应的URL支持哪些请求头可以跨域Access-Control-Max-Age
它代表上面两个请求头的有效时间(秒,默认为5秒)
让我们回到上面那个发送复杂请求的例子:
fetch(
'http://localhost:4000/greet',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ilija'})
}
).then(resp => resp.text()).then(console.log)
我们早就确定当我们发送这个请求时,我们的浏览器会和服务器一起检测是否可以发送跨域请求。为了得到一个可以发送跨域请求的环境,我们需要首先在服务器实现 OPTIONS /greet
请求。在它的响应头里面将会允许浏览器发送那个请求头是 Content-type: application/json
,从源是 https://www.google.com
发送的那个 POST /greet
请求(意思就是先解决预请求的问题,让POST跨域请求可以发送到服务器)。
我们会通过使用 Access-Control-Allow-*
请求头来实现:
options "/greet" do |env|
# Allow `POST /greet`...
env.response.headers["Access-Control-Allow-Methods"] = "POST"
# ...with `Content-type` header in the request...
env.response.headers["Access-Control-Allow-Headers"] = "Content-type"
# ...from https://www.google.com origin.
env.response.headers["Access-Control-Allow-Origin"] = "https://www.google.com"
end
如果我们重启服务,然后再次发送请求:
我们的请求还是被阻止了。即使我们实现了允许跨域请求的 OPTIONS /greet
请求,我们还是看到了报错信息。在控制台网络tab下有趣的事情发生了:
那个 OPTIONS /greet
终于通了!但是 POST /greet
请求仍然是失败。如果我们看一下 POST /greet
请求的详细情况,我们会看到相似的场景:
事实上,这个POST请求确实成功了——服务器返回了200的状态码。预请求成功了——浏览器成功请求了 POST
请求而没有阻止它。但是 POST
请求的响应头没有有关CORS的字段,所以即使请求成功了,它的响应执行还是被阻止了。
为了允许浏览器能成功执行 POST /greet
请求的响应,我们需要在服务器的 POST
请求做下面的操作:
post "/greet" do |env|
name = env.params.json["name"].as(String)
env.response.headers["Access-Control-Allow-Origin"] = "https://www.google.com"
"Hello, #{name}!"
end
在响应头里面添加 Access-Control-Allow-Origin
字段,以告诉浏览器 https://www.google.com
的tab可以访问到跨域请求的响应内容。
如果我们再请求一次:
我们看到 POST /greet
请求没有错误,成功的返回了内容。如果我们看一下控制台的网络tab,可以看到两个请求都是绿色(两个请求都成功了)。
通过在服务器中 OPTIONS /greet
预请求添加合适的响应头,我们解锁了浏览器对应跨域请求的执行。而根据上面在服务器中为 POST /greet
请求提供正确的CORS响应头,我们让浏览器没有阻止得执行了跨域请求的响应。
译者:我们可以发现,简单请求和复杂请求,跨域请求和非跨域请求,这两个功能是出于不同的目的设置的两个机制。
跨域读
在之前我们注意到,跨域读默认是被禁止的。这是故意设置的——因为我们不想在自己的源里面加载 其它源的子资源。
所以,我们在服务器里面添加一个 GET /greet
请求:
get "/greet" do
"Hey!"
end
回到我们的 www.google.com
页面,如果我们使用 fetch
去请求我们刚刚在服务器添加的请求,我们会被跨域阻止:
如果我们查看请求的详细信息,我们会发现一些有趣的事:
事实上,就像之前,我们的浏览器发送了那个请求,我们也得到了200的状态码。钽是请求的响应却不能在我们打开的页面上执行。又一个例子:CORS不阻止请求,阻止响应。
就像跨域写一样,我们可以通过添加 Access-Control-Allow-Origin
响应头让CORS策略让跨域请求通过。
get "/greet" do |env|
env.response.headers["Access-Control-Allow-Origin"] = "https://www.google.com"
"Hey!"
end
当浏览器得到服务器返回的响应时,它会寻找 Access-Control-Allow-Origin
响应头,并根据具体的值决定是否让页面读到响应的结果。在当前例子中响应头的返回值是 https://www.google.com
,就是我们当前的页面所以请求才可以成功。
浏览器就是通过识别服务器返回的响应头的值来从跨域读中保护我们的。
如何使用好CORS
我们在之前的例子就已经知道了,为了让我们的网站通过CORS策略,我们需要在 /greet
请求设置我们在之前的例子就已经知道了,为了让我们的网站通过CORS策略,我们在 /greet
请求设置 Access-Control-Allow-Origin
请求头为 https://www.google.com
的值:
post "/greet" do |env|
body = env.request.body
name = "there"
name = body.gets.as(String) if !body.nil?
env.response.headers["Access-Control-Allow-Origin"] = "https://www.google.com"
"Hello, #{name}!"
end
这将会允许 https://www.google.com
源访问我们的服务器,我们的浏览器可以正常运作。设置好 Access-Control-Allow-Origin
响应头,我们可以再请求一次:
它能正常工作!通过CORS策略,我们 https://www.google.com
的页面可以请求我们服务器上的 /greet
请求,或者我们可以将响应头的值设置成 *
,来告诉浏览器服务器可以被任何源访问。通过CORS策略,我们 https://www.google.com
的页面可以请求我们服务器上的 /greet
请求,或者我们可以将响应头的值设置成 *
,来告诉浏览器服务器可以被任何源访问。
必须仔细考虑这种配置,虽然,宽松配置CORS策略几乎总是安全的。其中一种经验做法是:你打开一个隐身tab,对显示的信息感觉满意,那你配置CORS策略为 *
。
另外一种使用好CORS策略的方法是使用 Access-Control-Allow-Credentials
响应头。这个响应头指示浏览器当请求的 credentials mode
值为 include
的时候,允许去执行响应的内容。
请求的 credentials mode
介绍来自 Fetch API,但是真的来源是一开始的 XMLHttpRequest
对象:
var client = new XMLHttpRequest()
client.open("GET", "./")
client.withCredentials = true
在Fetch API的介绍里面,那个 withCredentials
参数变成了fetch调用时的一个可选参数。
fetch("./", { credentials: "include" }).then(/* ... */)
可选参数 credentials
的值有 omit
,same-origin
和 include
。不同的模式都是可用的所以开发者们可以使用它来微调跨域请求。从服务器返回的带 credentials
字段的响应将会指示浏览器的行为。
Fetch API标准是精心编写,严格区别是CORS和 fetch
Web API,还包含了浏览器的安全机制。
一些好例子
在结束之前,让我们来做一些跨域的例了吧。
所有请求都允许
一个众所周知的例子是:如果你想提供一个公共内容的网站,而不需要付费或者登录。那么你可以将资源请求设置为 Access-Control-Allow-Origin: *
。
这个 *
值在如下条件的时候是一个好选择:
- 不需要登录或授权的时候
- 资源不受限制的被广泛的用户访问的时候
- 访问资源的来源和用户种类众多,你对此一无所知或者根本不在乎的时候
这种配置的危险是当你为内网(在防火墙或VPN之后)提供内容时,只有你连接上一个VPN时,你才能访问公司内网的文件:
现在,如果一个黑客网站 danguages.com
,里面有一个链接,指向一个VPN内部的文件,理论上他们只在外网上编写脚本就可以访问到内网的文件:
虽然攻击是困难的而且需要很多有关VPN的知识,但是这还是一个潜在的必须小心的攻击点。
Keeping it in the family
继续上面的例子,想像我们的网站需要一个分析功能。我们想让我们的用户发送用户在我们网站行为的数据到服务器上。
一种常见的方法是使用在浏览器中使用异步请求定期发送数据。在后端我们有一个简单的API响应请求并在后端存储这些数据,等待进一步的处理。
在这样的例子中,我们的API是公共的,但是我们又不想让其它网站的数据发送到我们的分析API上。实际上,我们只对源是我们浏览器渲染的页面的请求感兴趣,仅此而已。
在这几个例子里,我们想让在网站访问我们的API设置 Access-Control-Allow-Origin
请求头。这将确定浏览器不会让其它页面发送请求到我们的API。
如果用户或者其它网站尝试访问我们的分析API时,那个 Access-Control-Allow-Origin
响应头不会让这种请求通过:
NULL域
另一个有趣的例子是 null
域。这发生在浏览器加载本地文件访问远程资源时。
另一个有趣的例子是 null
域。这发生在浏览器加载本地文件访问远程资源时。例如:当你本地电脑上的静态文件中运行的脚本要发送请求时 Origin
请求头就将设置为 null
。
在这样的例子中,如果我们的服务器不允许 null
域访问资源,那可以会阻碍开发人员的工作效率。如果你有网站的用户是开发人员,那就在CORS中允许 null
域吧。
跳过Cookies,如果你能的话
之前我们讲过了 Access-Control-Allow-Credentials
,知道默认cookies是不能跨域的。为了让cookies能跨域,简单的方法就是设置 Access-Control-Allow-Credentials: true
请求头。这个请求头会告诉浏览器允许在跨域请求中发送凭证(就是cookies)。
允许和接受跨域请求的cookies可能是棘手的。你可能遇到潜在的攻击,所以只在绝对必要的时候开启它。
跨域请求需要带cookies的最好使用场景就是你知道的客户端访问你的服务器时。这就是为什么CORS提示我们当跨域请求凭证被允许时,不允许我们设置 Access-Control-Allow-Origin: *
。
设置 Access-Control-Allow-Origin: *
和 Access-Control-Allow-Credentials: true
在技术上是被允许的,但是它其实是一种反模式,应该被绝对避免。
如果你就喜欢你的服务器被不同的浏览器和不同的域访问,你也应该建立一个API,而不是使用cookies。但是如果API路径不可用,确信你实现了跨站请求伪造保护策略。
附加阅读
我希望这篇文章能让你对CORS有所了解,知道它从哪里来,为什么是必要的。这里有一些我在写这篇文章时用到的链接,我认为也是很好的资料:
- Cross-Origin Resource Sharing (CORS)
- Access-Control-Allow-Credentials 请求头
- Authoritative guide to CORS (Cross-Origin Resource Sharing) for REST APIs
- The “CORS protocol” section of the Fetch API spec
- Same-origin policy on MDN Web Docs
- Quentin’s great summary of CORS on StackOverflow
后记
我自己使用node+express实现了一个功能相同的后端。
var express = require('express');
var bodyParser = require("body-parser");
var app = express();
app.use(bodyParser.json()); // for parsing application/json
app.get('/', function (req, res) {
res.send('<h1>你好,这是我们的第一个nodejs项目</h1>');
});
app.options('/greet', function(req, res) {
res.header('Access-Control-Allow-Methods', 'POST');
res.header('Access-Control-Allow-Headers', 'Content-type');
res.header('Access-Control-Allow-Origin', 'https://www.baidu.com');
res.send()
})
app.post('/greet', function(req, res) {
let name = req.body.name
console.log(name);
res.header('Access-Control-Allow-Origin', 'https://www.baidu.com')
res.send('Hello ' + name)
})
app.get('/greet', function(req, res) {
res.header('Access-Control-Allow-Origin', 'https://www.baidu.com');
res.send('Hey!')
})
app.listen(4000);
weixin_48594968: 老实说你这个说的不清不楚啊,配置了classpath后程序才不行,不配置前还能运行,那配置来干嘛呢
夙染尘: 单独用的Java的时候一般不需要class path,后面和服务器之类的联合的时候会需要
我爱人工智能: 干货满满,很详细,评论占个坑,期待大佬回访!
我爱人工智能: 写的很详细
LaoYuanPython: 很荣幸阅读博主新鲜出炉的博文!谢谢大神的细致介绍!如此好文,点赞一个!