CORS学习分享
前言
跨域资源共享CORS,也就是我们常说的跨域问题。相信很多做web开发的小伙伴听到这个词,都不陌生还很熟悉,甚至能说出一两个的跨域的解决方案。
基于近些年大火的前后端分离架构和浏览器的安全规范越来高的背景下,跨域问题出现的频率很频繁,无论是前端或或者是后端都有可能碰到这个问题(周三的时候讨论emr电子病历模板库的时候就遇到跨域问题)。而且跨域是每个web开发人员必须了解掌握的一个知识点。
所以呢,我整理了些常见的跨域问题和解决方案。听完之后,希望小伙伴能够更加理解跨域的问题。(虽然本次分享的主要受众是的开发人员,但是其他相关的角色我觉得也有必要了解下)
以下就分为这几部分来讲解跨域问题,首先看下CORS是什么,为啥会发送CORS错误,第二部分就是讨论如何解决CORS,第三部分CORS详解。
第一章 为什么会发生CORS错误?
在正式开始以前,想先跟大家讲一个小故事,跟跨域有关的一个小故事,反正大家就当一个无厘头故事听听就好,等真正理解完整个跨来源请求相关的东西以后,就知道这故事代表什麽了。
故事的主角还是小明,由于被当成嫌疑犯而被警察安置在一个小房间,没办法外出,跟外界的沟通都要通过警卫。小明身为嫌疑人,肯定要为自己洗清嫌疑,所以他会向警卫问很多问题,例如说:“明天的天气如何”、“我的存款剩下多少?”、“我爸妈过得好吗”等等。
针对小明的每一个提问,警卫都会帮他去问到当事人,但不一定会把答案告诉小明。因为警察规定了一个程序,那就是“除非被问的人明确同意,不然不能把答案告诉小明”,所以警卫会先问完问题拿到答案,再问说:“请问你愿意让小明知道这件事吗?”。
有些人愿意,例如说问气象局明天的天气,毕竟这类资讯告诉谁都可以。 但也有些人不愿意,因为根本不相信小明。 还有一种状况,警卫连问都不用问,那就是小明的家人。因为小明的家人跟小明血脉相承,系出同源,肯定是相信小明的,所以可以直接放行。
于是呢,尽管小明的每一个问题都有传达到被问的人那裡,却不一定能收到回复。有一天小明终于受不了这种被囚禁的生活,于是想了几个方法。
第一个方法是把警卫打倒逃出去,没有警卫了他就自由了,想问谁问题就问谁,不用再透过警卫,完全没有任何拘束。
第二个方法是请一个律师帮忙当代理。每当小明有问题时,都跟警卫说:“你去问我律师这个问题”,接著律师再去当事人得到结果,再把结果跟警卫讲。顺便交代警卫他愿意让小明知道这件事。这样呢小明就可以得到这个问题的答案了。
第三个方法是让大家都愿意把资讯告诉他,这样就不会被警卫拦截,就能顺利知道问题的答案。
好,故事结束了,虽然没有很贴切就是了,不过浮夸的故事总是比较吸引人注意,以上的故事就是我今天分享的大致内容,后边你们就清楚了,接著让我们来进入主题。
1. 从错误开始
Access to XMLHttpRequest at 'http://localhost:93/' from origin 'http://localhost:91' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
在前端用Ajax(XMLHttpRequest或支持fetch)请求服务端API的时候,应该都有碰到过这个这个错误,那为什么会会有这个错误呢?
其实是浏览器因为安全性的考量,有一个东西叫做同源策略
2. 什么是同源策略(Same Origin Policy)
同源策略是一个规范,意思就是说在浏览器中采用Ajax(XMLHttpRequest或支持Fetch API)进行跨源请求的时候,浏览器会帮你发Request,但是会把Response 给挡下来,返回CORS错误,不让你的JavaScript 拿到数据。
强调几个关键点,「
你的Request 还是有发出去的
」,而且浏览器也「确实有收到Response
」,重点是「浏览器因为同源策略,不把结果传回给你的JavaScript
」。如果没有浏览器的话其实就没有这些问题,你爱发给谁就发给谁,不管怎样都拿得到Response。
什么是不同源呢?
两个URL只要 协议、主机、端口号中有一者不同,则他们就是不同源。
序号 | URL1 | URL2 | 结果 | 原因 |
---|---|---|---|---|
1 | http://example.com/app1/index.html | http://example.com/app1/index.html | 同源 | 协议、主机和端口号都一样 |
2 | http://Example.com:80 | http://example.com | 同源 | 服务器默认通过端口 80 传递 HTTP 内容 |
3 | http://example.com/app1 | https://example.com/app2 | 不同源 | 协议不一样 |
4 | http://example.com | http://www.example.com | 不同源 | 主机不一样 |
5 | http://example.com | http://example.com:8080 | 不同源 | 端口不一样 |
第4点是大家要特别注意的一点,domain 跟subdomain 之间也是不同源的,所以 example.com
跟 www.example.com
不同源。有很多人常常会把这个跟cookie 搞混,因为 example.com
跟 www.example.com
是可以共用cookie 的。在这边特别强调,cookie 比对的规则叫做:Domain Matching ,它是看domain 而不是看我们这边所定义的origin,千万不要搞混了。
从以上范例可以得知,其实要达成同源满困难的,如果只看网址的话,基本上要长得一模一样,只有path 跟后面的部分可以不一样。
Ajax为什么不能跨源请求API?
好,既然刚刚说了不同源的请求会被挡下来,那为什么HTML里的img 跟script 标签不受同源策略限制,可以跨源抓取资源,但AJAX 却不行呢?
img标签抓取的目标只是图片,所得到的资源没办法用程式去读取它,因此不会有安全问题。script 标签设计之初就允许跨源引用。正是因为script有这个特性,所以可以利用这个“漏掉”实现跨域,就是JSONP,这个后边会说到。
同源策略只会对JS程序所发出的跨源 HTTP 请求进行限制,例如Ajax(XMLHttpRequest或Fetch API)请求,如果不进行同源策略限制,浏览器很容易受到XSS等攻击。总而言之,都是浏览器基于安全性考量;
3. 什么是跨源资源共享(Cross-Origin Resource Sharing,简称CORS)
已经知道了同源策略,与之相对的,如果你想实现在不同源之间进行HTTP传输数据的话,你应该怎么做?这规范就叫做CORS。
第二章 如何解决CORS问题?
想要解决基本的CORS 错误,其实有满多种方法:
- 关掉浏览器的安全性设置
- 把fetch mode 设成no-cors
- 不要用AJAX 拿资料(JSONP/WS)
- 后端设置CORS Header
- 使用Proxy Server
1. 关掉浏览器的安全性设置
在上一篇里面有再三跟大家提过,跨来源请求会被挡住,是因为浏览器的限制。所以只要浏览器没有这个限制,就能平平安安快快乐乐拿到response。
因此解决CORS 问题的方法之一,就是直接把浏览器的安全性设置关掉,简单暴力又有用。
至于怎么关闭,如果是Chrome 的话可以参考:Run Chrome browser without CORS,其他浏览器的话就要自己查一下相关资料了。
把安全机制关掉以后,就可以顺利拿到response,浏览器也会跳一个提示出来:
问题是解决了,但为什么我说这是治标不治本呢?因为只有在你电脑上没问题而已,在其他人的电脑上面还是有问题。而且你关掉的不只是CORS,你连其他安全机制也一起关掉了。总之呢,只是跟大家介绍有这个解法,但不推荐使用。
2. 把fetch mode 设成no-cors
如果你是使用fetch 去抓取资料,例如说这样(这个网页的origin 是http://localhost:91,跟 http://localhost:93 不同源):
xxxxxxxxxx
fetch('http://localhost:93').then(res => {
console.log('response', res);
return res.text();
}).then(body => {
console.log('body' , body)
})
你就会看到console 上面跳出显眼的红字:
前半段很熟悉,后半段可能就比较陌生一点。但没关系,我们看到了关键字:set the request's mode to 'no-cors'
,喔喔喔,难道说这样就可以不管CORS 吗?马上来试试看:
xxxxxxxxxx
fetch('http://localhost:93', {
mode: 'no-cors'
}).then(res => {
console.log('response', res);
return res.text();
}).then(body => {
console.log('body' , body)
})
添加mode: 'no-cors'
之后重新执行,果真不会跳错误出来了!console 一片干净,只是印出来的值似乎怪怪的:
Response 的status 是0,body 的内容是空的,type 是一个叫做 opaque
(不透明)的东西,看起来很奇怪。但如果我们切到Network 的那一个tab 去看,会发现其实后端是有回传response 的。所以设置这个mode 以后,并不会神奇地就让你可以突破限制拿到东西,正好相反,这个模式是在跟浏览器说:「我就是要发request 给一个没有cors header 的资源,我知道我拿不到response,所以你绝对不要给我response」。这就是一个自欺欺人的方法;
3. 不要用AJAX 拿资料(JSONP/WS)
既然用AJAX 会被挡跨来源的请求,那如果可以不用AJAX 拿资料,不就没有问题了吗?在上一章我有提过,有一些HTML标签是不会受到同源策略的限制,例如说img 或者是script…对,script!
script 一般来说都是引入写好的服务端JS文件,浏览器得到数据后会以JS程序来执行。利用这个特性,我们可以让服务端接口来返回带有真实数据的JS代码。同时为了让浏览器能够自动回调函数,我们还必须告诉服务端我定义的函数名,让服务端获取到这个动态函数名称来包裹真实数据并回传客户端,所以这个方式必须要透过server 配合才能使用。
这个方法就是大名鼎鼎的JSONP,JSON with Padding(padding 是填充的意思,可以想成就是前面填的那个function 名称),JSONP已经形成一种方式或者说非强制性协议,填充的数据格式不一定非要用Json格式,如果你愿意,字符串都行。
这个方法在早期CORS 的规范还不完全时挺常用的,巧妙地跨过了浏览器的安全性限制。不过它的缺点是因为你只能用script 的方式去请求,所以只能用GET 这个method,其他POST、PATCH、DELETE 什么的都不能用。
除了JSONP,我在想是不是可以通过websoket进行前后端数据交互?
websoket实现了浏览器与服务器全双工通信,同时允许跨域通讯,是一种很好的跨域解决办法。小伙伴可以自己尝试下,到时候我也会在另外一篇博文中补充:CORS请求之非安全网站发起的专用网络请求
4. 请后端设置CORS header(真正的解法)
还记得一开始跨域时出现的那个错误吗?
Access to XMLHttpRequest at 'http://localhost:93/' from origin 'http://localhost:91' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
重点是这一句:No 'Access-Control-Allow-Origin' header is present on the requested resource
刚刚有提到说后端才是拥有权限的那一方,可以告诉浏览器:「我允许这个origin 跨来源存取我的资源」,而告诉浏览器的方法,就是在response 加上一个header。
这个header 的名称叫做Access-Control-Allow-Origin
,内容就是你想要放行的origin,例如说:Access-Control-Allow-Origin: http://127.0.0.1:91
,这样就是允许 http://127.0.0.1:91
的跨来源请求。
如果只想针对特定的origin 开放权限,只要传入要开放的origin 就行了。放*
,就代表允许任何origin 的意思。如果想要针对多个origin,server 那边必须做一点额外处理。
这才是从根本去解决跨来源请求的问题的方式,如果你跟存取资源的服务端有合作关系的话,直接请他们设定这个header 就行了。但是时候,你可能会想从「跟你没有合作关系」的服务端获取数据,例如说你想去抓 google.com 的内容之类的,这些资源绝对不会给你 Access-Control-Allow-Origin
这个header。
这时候怎么办呢?
让我们欢迎proxy server 登场!
5. 使用proxy server
前边不断提醒大家,同源策略什么的都只是「浏览器的限制」,一旦脱离了浏览器,就没有任何限制了,proxy server 就是如此。
Proxy server 的翻译叫做代理伺服器,一般采用Nginx。假设客户端端口是91,后端服务器端口93,服务器未设置CORS header。从客户端到服务器的请求肯定是跨域了,现在用代理以后变成客户端先把请求传到代理服务器,再由代理传到后端服务器,然后再回传回来。Nginx 就担任着「代理」的角色。因为Nginx和后端服务器都属于服务器之间的交互,不会受浏览器的同源策略的限制,因此就可以解决跨域问题。
注意,作为代理服务器Nginx需要设置CORS header,不然客户端和代理之间也会出现跨域限制
前后端代码以及Nginx配置如下:
第三章 CORS详解
在上一篇里面我们提到了常见的CORS 错误解法,以及大多数状况下应该要选择的解法:「请后端加上response header」。
但其实「跨来源请求」这个东西又可以再细分成两种,简单请求跟非简单请求,简单请求的话可以透过上一篇的解法来解,但非简单请求的话就比较复杂一些了。
除此之外,跨来源请求预设是不会把cookie 带上去的,需要在使用xhr 或是fetch 的时候多加一个设定,而后端也需要加一个额外的header 才行。
与CORS 相关的header 其实不少,有些你可能听都没听过。原本这篇我想要把这些东西一一列出来讲解,但仔细想了一下觉得这样有点太无趣,而且大家应该看过就忘记了。建议大家课后自己学习。
简单的跨域请求
以第二章示例的请求都是「简单请求」,只要满足以下两点基本上就可以被视为是「简单请求」
- 请求头
Content-Type
头值须为application/x-www-form-urlencoded
、multipart/form-data
或text/plain
其中之一; - HTTP请求中没有使用自定义的标头(如
X-token
等)。
非简单的请求
一般的只要出现以下一种情况就可以视作「非简单请求」 ,非简单请求下浏览器会自动多送出一个请求,这个请求叫做preflight request(预检请求),预检请求的作用查看请求的接口是否支持当前跨域请求。
-
请求头中
Content-Type
的值非application/x-www-form-urlencoded
、multipart/form-data
、text/plain
其中之一; -
带有自定义的 HTTP 标头(如
X-token
)的请求; -
执行修改服务器数据的方法时,例如
PUT
、DELETE
、PATCH
方法。现在我们写一个用JSON 的方式传数据到后端的代码:
浏览器运行后,切到Network tab 去看request 的状况,发现除了原本预期的POST 以外,还多了一个方法为OPTIONS 的预检请求,浏览器会帮忙带上两个header:
预检请求的作用就是查看服务器是否支持当前的跨域请求。预检请求的response 需要明确用 Access-Control-Allow-Headers
来表明:「我愿意接受这个header」,浏览器才会判断预检通过。
而在这个案例中,content-type
就属于自定义header,所以后端必须明确表示愿意接受这个header:
xxxxxxxxxx
res.header('Access-Control-Allow-Headers', 'Content-Type');
如此一来,前端那边就可以顺利通过preflight request,只有在通过preflight 之后,真正的那个request 才会真正发出。
基本身份验证请求
在这个用得比较少,感兴趣的小伙伴可以自己查下资料。
非安全网站发起的专用网络请求
这是个比较特殊的CORS规范,从Chrome94开始试验,有另一篇文章来说明他:CORS请求之非安全网站发起的专用网络请求
总结
- CORS
- 同源策略(浏览器基于安全性出的一个限制,限制非同源下的脚本的Ajax请求)
- 跨源资源共享(解决同源策略问题的机制,实现跨源脚本请求)
- CORS方案
- 关掉浏览器的安全性设置(不推荐)
- 把fetch mode 设成no-cors(自欺欺人)
- 不要用AJAX 拿资料(JSONP/WS)
- 请后端设置CORS header(真正的解法)
- 使用proxy server
- CORS请求
- 简单请求
- 非简单请求(预检请求)
参考资料 CORS 完全手册