跨域解决
什么导致了跨域
跨域
首先,网络是没有绝对安全可言的,但是,我们又需要使用浏览器来访问网络,所以浏览器能存在的安全基础就是有相对较高的安全性,提升了别人做坏事的成本。
Same-origin policy(同源策略,以下简写为 CORS)就是浏览器安全的一个重要部分。
同源策略是一个重要的安全策略,它用于限制一个源(Origin)的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。
同源策略的目的有两个:
- 对系统用户:保证用户信息的安全,防止恶意的网站窃取数据。
- 对开发人员:约束网站使用的资源尽可能是同源可控或者信任的资源,减少问题的可能性,从而来增强网站的安全性。
真实案例(仅限演示例子使用):
在登录腾讯文档后,打开一个文档,在控制台调用下面的代码,将会给一个好友分配该文档的编辑权限。
1 | $.ajax({ |
退出账号后,调用相同代码,不能给相同好友分配该文档的编辑权限。并报下面的错误。
1 | cgicode: 11000; |
由报错可以看出,出现问题是因为 cookie 中缺少相关信息,导致服务端认证失败。
在其他网站的控制台调用相同代码会报如下错误:
1 | Access to XMLHttpRequest at 'https://docs.qq.com/cgi-bin/redirect/300000000/ep/api/setpadinfo?localPadId=BYtWkVVnlVCW&type=1&ver=2&route_ip=&room_route_ip=&get_vip_info=1' from origin 'https://fanyi.baidu.com' has been blocked by CORS policy: 、No 'Access-Control-Allow-Origin' header is present on the requested resource. |
现在根据上面的情况试想在没有同源策略的情况下:
在你登陆腾讯文档后,再打开一个其他源的网站,调用上面的代码,也是会成功给好友分配编辑权限的。进而联想到在一个第三方网站掌握腾讯文档的接口信息规则后,就可以假冒你对你的账号进行任意操作,这是十分可怕的。
那么怎么判断是同一个源呢,它的定义是什么呢?
同源的定义:
如果两个 URL 的协议、主机 和 端口 (如果有指定的话) 都相同的话,则这两个 URL 是同源。这个方案也被称为“协议/主机/端口元组”。
注:
- 只有浏览器才会有跨域问题,因为只有浏览器才有同源策略。
- 恶意文档:可能导致推栈缓冲区溢出,从而在电脑中执行一些代码的文件,一般指病毒或者木马的运行程序。
跨域示例
下表给出了与 URL:http://store.company.com/dir/page.html 的源进行对比的示例:
URL | 结果 | 原因 |
---|---|---|
http://store.company.com/dir2/other.html | 同源 | 只有路径不同 |
http://store.company.com/dir/inner/another.html | 同源 | 只有路径不同 |
https://store.company.com/secure.html | 失败 | 协议不同 |
http://store.company.com:81/dir/etc.html | 失败 | 端口不同 ( http: 默认端口是 80;https: 默认端口是 443) |
http://news.company.com/dir/other.html | 失败 | 主机不同 |
注:域名和 IP 指向一样,但是还是会引起跨域的。
例如:http://www.ths.com.cn/和http://223.223.179.206/都指向同一个地方
限制范围
随着互联网的发展,”同源策略”越来越严格。目前,如果非同源,共有三种行为受到限制。
- Cookie、LocalStorage、SessionStorage 和 IndexDB 无法读取。
- 脚本 API 访问。
- AJAX 请求不能发送。
常见跨域错误
缺少可跨域响应头
关键字: No ‘Access-Control-Allow-Origin’ header (Access-Control-Allow-Origin 翻译:允许访问的源)
注:客户端发送的叫请求,服务器端返回的叫响应
生动例子:客户端向服务器借钱是请求,服务器不给钱是对客户端的响应
允许跨域的值重复
关键字: The ‘Access-Control-Allow-Origin’ header contains multiple values ‘*, *‘, but only one is allowed。 (”允许访问的源“响应头包含重复的两个*号,但是只有一个是允许的)
原因: 多次代理的时候配置的允许跨域的值有重复的。
不允许访问其他域的对象或者数据存储(cookie 等)
关键字:block a frame with origin form accessing a cross-origin frame (访问了一个跨域的源)
解决跨域
跨域网络访问(Cross-origin network access)
因为跨域产生的原因是两个资源不在同一个域,所以有四种解决的办法:
- 把两个资源放到同一个域中
- 服务器允许资源跨域共享(CORS)
- 浏览器插件
- 利用一些不受同源策略影响的标签实现(
script
)
把两个资源放到同一个域中
- 反向代理:通过 nginx 把多个资源代理到一块
- 参考 nginx 说明文档
注: 在能操作文件的情况下,不建议多次代理,因为这样会导致网络传输变慢,影响系统的流畅性。
- 参考 nginx 说明文档
- 正向代理:通过同域的接口返回其他域的资源
注:
- 反向代理:访问自定义的接口来返回其他接口的资源,通过自定义接口已经不能分出真实资源来自哪里。
- 正向代理:由代理服务器去请求资源并返回给你,访问还是原来的真实网址。
- 不管是正向代理还是反向代理,都是由服务器端去进行访问的,所以要保证在服务器端能访问到真实地址。
服务器允许资源跨域共享
- Tomcat
在 Tomcat 根目录 –> conf –> web.xml 的 web-app
节点下加入如下代码。
1 | <filter> |
注: Tomcat 配置修改后必须重启 Tomcat 才会生效。
- Nginx
在 Nginx 的根目录 –> conf –> nginx.conf 文件适当写入下述代码。- 全局可跨域
1 | # 在 server 节点下添加以下语句 |
- 指定接口可跨域
1 | # 在指定的 location 节点下添加,示例如下: |
注:
- Nginx 配置修改后必须重启才会生效。
- 同时添加全局可跨域和指定接口可跨域时,访问指定接口会产生可跨域响应头重复的问题,可使用指定接口代理,因此不推荐使用全局可跨域。
- 注意
localtion
的匹配规则 - 当配置指定域可跨域时,可把
*
换为具体的域名或者 IP,多个之间用逗号隔开。 - 多次代理设置具体相同的域也是会报允许跨域的值重复错误
- 多次代理设置域时,
http:127.0.0.1:10229/ === http:127.0.0.1:10229
补:location 和 proxy_pass 易混淆点
location (后面两点为 URL 访问的相关知识补充)
location
后面带不带/
都是一样的如果 URL 的格式为
http://my.suyp.com/
或者http://127.0.0.1:10229/
,**尾部有没有/
都不会造成重定向**。
因为这种情况下,浏览器请求时会在后面默认加上/
。可以这样理解,没有请求是访问的http://my.suyp.com
,都是访问的http://my.suyp.com/
,所以没有重定向。如果 URL 的格式为
http:127.0.1:10229/node/
。尾部如果缺少/
将导致重定向。
因为根据约定,URL 尾部的/
表示目录,没有/
表示文件。所以访问/node/
时,服务器会自动去该目录下找对应的默认文件或者返回该目录的文件列表。如果访问/node
的话,服务器会先去找node
文件,找不到的话会将node
当成目录重定向到/node/
,去该目录下找默认文件或者返回该目录的文件列表。
proxy_pass
假设下面四种情况分别用 http://192.168.1.1/proxy/test.html 进行访问。1
2
3
4location /proxy/ {
proxy_pass http://127.0.0.1:10229/;
}
# 代理到URL:http://127.0.0.1:10229/test.html1
2
3
4location /proxy/ {
proxy_pass http://127.0.0.1:10229; # (相对于第一种,最后少一个 / )
}
# 代理到URL:http://127.0.0.1:10229/test.html1
2
3
4location /proxy/ {
proxy_pass http://127.0.0.1:10229/aaa/;
}
# 代理到URL:http://127.0.0.1:10229/aaa/test.html1
2
3
4location /proxy/ {
proxy_pass http://127.0.0.1:10229/aaa; # (相对于第三种,最后少一个 / )
}
# 代理到URL:http://127.0.0.1:10229/aaatest.html理解:
如果最后有/
,就是把 URL 以location
路由切割,把后面的部分放到代理地址的后面,如果没有,就是把路由加上后面的部分放到代理地址的后面。(注意:http://127.0.0.1
===http://127.0.0.1/
)。
推荐配置代理的写法:
1 | location /proxy/ { |
- Node
- 全局可跨域
1 | app.all("*", (req, res, next) => { |
- 指定接口可跨域
1 | app.get("/node", (req, res) => { |
注:因为 Node 的路由是由上到下匹配的,有符合的默认就不继续向下匹配了 + 指定全局可跨域时,一定要先写app.all
+ 在app.all
中,一定要加next()
, 来让路由继续向下匹配
浏览器插件
在 chrome 网上商店中搜索 Allow CORS: Access-Control-Allow-Origin 插件安装,在需要的时候运行即可。
利用一些不受同源策略影响的标签
JSONP
JSONP 是服务器与客户端跨源通信的一种方法。最大特点就是简单适用,老式浏览器全部支持,服务器不用做任何改造。
理论基础:Web 页面上调用 js 文件时是不受否跨域的影响(不仅如此,凡是拥有”src”这个属性的标签都拥有跨域的能力,比如<script>
、<img>
、<iframe>
)。
因为 JSONP 是利用script
标签的特性来实现跨域的,所以不支持post
请求。
基本思想:在远程服务器上设法把数据装进 js
格式的文件里,供客户端调用和进一步处理。
首先,网页动态插入<script>
元素,由它向跨源网址发出请求。
1 | function addScriptTag(src) { |
上面代码通过动态添加<script>
元素,向服务器localhost:1000
发出请求。注意,该请求的查询字符串有一个callback
参数,用来指定回调函数的名字,这对于 JSONP 是必需的。
服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。
1 | jsonpExe({ data: "我是jsonp的数据" }); |
由于<script>
元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了foo
函数,该函数就会立即调用。作为参数的 JSON 数据被视为 JavaScript 对象,而不是字符串,因此避免了使用JSON.parse
的步骤。
或者使用 AJAX 调用,示例如下:
1 | function getJSONPData() { |
表单提交数据不受同源策略的影响
- 表单提交:
1 | <!-- 成功 --> |
- ajax 请求:
1 | // err 跨域 |
发散一下思维:
虽然form
标签只能发送数据,但是没有跨域问题,所以在只需要发送消息的时候也可以使用form
来进行单方面通信。
Canvas 中的跨域问题
受影响的方法如下:
- getImageData():
返回一个ImageData
对象,用来描述 canvas 区域隐含的像素数据 - toBlob():
创造Blob
对象,用以展示 canvas 上的图片;这个图片文件可以被缓存或保存到本地,由用户代理端自行决定。如不特别指明,图片的类型默认为image/png
,分辨率为96dpi
。 - toDataURL() :
返回一个包含图片展示的 data URI 。可以使用 type 参数其类型,默认为PNG
格式。图片的分辨率为96dpi
。
下面以toDataURL()
为例。
在使用Canvas
绘制不同域的图片然后转为Base64
时,会有跨域问题。但是,不影响 Canvas 绘制展示图片。
1 | <body> |
报错:
解决方式:
- 设置图片的
crossOrigin = 'anonymous'
,来让toDataURL
方法不因跨域报错 - 设置图片可跨域
二者缺一不可。
1 | <body> |
不允许 IFrame 被嵌入
在响应头中有这么一个配置项 X-Frame-Options来标识一个页面是否可以被其他页面嵌入。
可选值:
- deny:表示该页面不允许在 frame 中展示,即便是在相同域名的页面中嵌套也不允许。
- sameorigin:表示该页面可以在相同域名页面的 frame 中展示。
- allow-from uri:表示该页面可以在指定来源的 frame 中展示。(uri 为指定源的地址)
配置:
Tomcat
在 Tomcat 根目录 –> conf –> web.xml 的 web-app 节点下加入如下代码。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17<!-- 配置页面是否能被其他页面展示 -->
<filter>
<filter-name>httpHeaderSecurity</filter-name>
<filter-class>org.apache.catalina.filters.HttpHeaderSecurityFilter</filter-class>
<init-param>
<param-name>antiClickJackingOption</param-name>
<!-- 配置的具体值 -->
<param-value>sameorigin</param-value>
</init-param>
<async-supported>true</async-supported>
</filter>
<filter-mapping>
<filter-name>httpHeaderSecurity</filter-name>
<url-pattern>/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22<!-- 配置页面是否能被其他页面展示 -->
<filter>
<filter-name>httpHeaderSecurity</filter-name>
<filter-class>org.apache.catalina.filters.HttpHeaderSecurityFilter</filter-class>
<init-param>
<param-name>antiClickJackingOption</param-name>
<!-- 配置的具体值 -->
<param-value>allow-from</param-value>
</init-param>
<!-- 当指定部分域可展示时,使用下面代码配置指定域 -->
<init-param>
<param-name>antiClickJackingUri</param-name>
<param-value>http://127.0.0.1:10229</param-value>
</init-param>
<async-supported>true</async-supported>
</filter>
<filter-mapping>
<filter-name>httpHeaderSecurity</filter-name>
<url-pattern>/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>注:目前测试指定域可嵌入无效,错误如下:
1
testIframe.html:1 Invalid 'X-Frame-Options' header encountered when loading 'http://localhost:8090/CORS/testIframe.html': 'ALLOW-FROM http://127.0.0.1:10229' is not a recognized directive. The header will be ignored.
Nginx
配置 nginx 发送 X-Frame-Options 响应头,把下面这行添加到 ‘http’, ‘server’ 或者 ‘location’ 的配置中:
add_header X-Frame-Options sameorigin always;
add_header X-Frame-Options deny always;
add_header X-Frame-Options allow-from 'http://127.0.0.1,http://127.0.0.2' always;
小结:
跨域网络访问推荐做法:在能随意操作文件的情况优先把资源分类放到一起,这样既不会有跨域请求问题,也方便管理。
不行的话,使用 Nginx 代理。
跨域数据存储访问(Cross-origin data storage access)
使用域名
document.domain 属性中存入的是主机信息,并且可以设置为当前域或者当前域的父域。如果将其设置为其当前域的父域,则这个较短的父域将用于后续源检查。
另外,任何对 document.domain 的赋值操作,包括 document.domain = document.domain 都会导致端口号被重写为 null。因此 company.com:8080 不能仅通过设置 document.domain = “company.com” 来与 company.com 通信。必须在他们双方中都进行赋值,以确保端口号都为 null 。
由于以上的情况,我们可以在多个只有二级域名不同的网页进行以下操作。
注:
Cookie
Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置 document.domain
共享 Cookie
。
举例来说,A 网页是**http://w1.example.com/a.html,B 网页是http://w2.example.com/b.html**,那么只要设置相同的 document.domain,两个网页就可以共享 Cookie。
1 | document.domain = "example.com"; |
现在,A 网页通过脚本设置一个 Cookie。
1 | document.cookie = "test1=hello"; |
B 网页就可以读到这个 Cookie。
1 | var allCookie = document.cookie; |
另外,服务器也可以在设置 Cookie 的时候,指定 Cookie 的所属域名为一级域名,这样二级域名和三级域名不用做任何设置,都可以读取这个 Cookie。
DOM
如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的 document.domain
属性,就可以规避同源策略,拿到 DOM。
父页面:
1 | window.onload = function () { |
子页面:
1 | window.onload = function () { |
Node 服务端:
1 | // 在开启服务的代码基础上新加如下代码 |
注:
- 同源 ajax 请求是可以自动携带 cookie 的
- 而非同源需要客户端和服务端都做处理:
- 客户端需要对 xhr 对象设置 withCredentials:true
- 服务端需要设置响应头 access-control-allow-credentials:true
- 同时必须指明 access-control-allow-origin 为服务方的 origin, 不能为*
新增属性:
Chrome 51 开始,浏览器的 Cookie 新增加了一个SameSite属性,用来防止 CSRF 攻击和用户追踪。
可选值:
- Strict
Strict
最为严格,完全禁止第三方 Cookie,跨站点时,任何情况下都不会发送 Cookie。换言之,只有当前网页的 URL 与请求目标一致,才会带上 Cookie。 - Lax
Lax
规则稍稍放宽,大多数情况也是不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外。 - None
None
相当于忽略该属性。不过,前提是必须同时设置 Secure 属性(Cookie 只能通过 HTTPS 协议发送),否则无效。
相关东西,后面补充。
小结:
跨域数据存储访问推荐做法:
- 把文件放在一起或者使用 Nginx 代理到一起 (推荐)
- 如果使用的域名可以考虑设置
document.domain
属性 - 完全不同源,可以考虑通过跨域通信的方式传递相关参数
跨域脚本 API 访问(Cross-origin script API access)
如果两个网页不同源,就无法拿到对方的 DOM。典型的例子是iframe
窗口和window.open
方法打开的窗口,它们与父窗口无法通信。
比如,父窗口运行下面的命令,如果 iframe 窗口不是同源,就会报错。
1 | document.getElementById("myIFrame").contentWindow.document; |
上面命令中,父窗口想获取子窗口的 DOM,因为跨源导致报错。
反之亦然,子窗口获取主窗口的 DOM 也会报错。
1 | window.parent.document.body; |
对于完全不同源的网站,目前有四种方法,可以解决跨域窗口的通信问题。
- 片段标识符(
fragment identifier
) - window.name
- 跨文档通信 API(
Cross-document messaging
) - WebSocket
片段标识符
片段标识符(fragment identifier)指的是,URL 的#号后面的部分,比如http://example.com/x.html#fragment
的#fragment
。如果只是改变片段标识符,页面不会重新刷新。也可以叫做锚点
, 用作页面定位.
父窗口可以把信息,写入子窗口的片段标识符。
1 | var srcStr = |
子窗口通过监听 hashchange 事件得到通知。
1 | window.onhashchange = checkMessage; |
同样的,子窗口也可以改变父窗口的片段标识符。
1 | parent.location.href = target + "#" + hash; |
注意:如果修改后的 hash 值和原来的一样,不会进片段标识符改变的监听
window.name
浏览器窗口有window.name
属性。这个属性的最大特点是,无论是否同源,只要在同一个窗口里,前一个网页设置了这个属性,后一个网页可以读取它。
父窗口先打开一个子窗口,载入一个不同源的网页,该网页将信息写入 window.name 属性。
1 | window.name = '{"name": "lisi"}'; |
接着,子窗口跳回一个与主窗口同域的网址。
1 | // 把子页面的window.location 设置为域和父页面同源的 |
然后,主窗口就可以读取子窗口的 window.name 了。
1 | var data = document.getElementById("myFrame").contentWindow.name; |
这种方法的优点是,window.name
容量很大,可以放置非常长的字符串;
代码实现:
1 | var state = 0; |
window.postMessage
上面两种方法都属于破解,HTML5 为了解决这个问题,引入了一个全新的 API:跨文档通信 API(Cross-document messaging
)。
这个 API 为 window 对象新增了一个方法 window.postMessage
,允许跨窗口通信,不论这两个窗口是否同源,只要你能获取到目标对象的 window 对象。
举例来说,父窗口http://aaa.com
向子窗口http://bbb.com
发消息,调用postMessage
方法就可以了。
1 | var popup = window.open("http://aaa.com", "title"); |
postMessage
方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin
),即”协议 + 域名 + 端口”。也可以设为*
,表示不限制域名,向所有窗口发送.
子窗口向父窗口发送消息的写法类似。
1 | window.opener.postMessage("Nice to see you", "http://bbb.com"); |
注:window.opener
属性是一个可读可写的属性,可返回对创建该窗口的 Window 对象的引用。
父窗口和子窗口都可以通过message
事件,监听对方的消息。
1 | window.addEventListener( |
下面的例子是,子窗口通过event.source
属性引用父窗口,然后发送消息。
1 | window.addEventListener("message", receiveMessage); |
event.origin
属性可以过滤不是发给本窗口的消息。
1 | window.addEventListener("message", receiveMessage); |
message 事件的事件对象 event,提供以下三个属性。
- event.source:发送消息的窗口
- event.origin: 消息发向的网址
- event.data: 消息内容
下面的例子是,子窗口通过event.source
属性引用父窗口,然后发送消息。
1 | window.addEventListener("message", receiveMessage); |
event.origin
属性可以过滤不是发给本窗口的消息。
1 | window.addEventListener("message", receiveMessage); |
通过 window.postMessage,读写其他窗口的 LocalStorage 或者 SessionStorage 也成为了可能。
下面是一个例子,主窗口写入iframe
子窗口的localStorage
。
1 | window.onmessage = function (e) { |
上面代码中,子窗口将父窗口发来的消息,写入自己的LocalStorage
。
父窗口发送消息的代码如下。
1 | var win = document.getElementsByTagName("iframe")[0].contentWindow; |
加强版的子窗口接收消息的代码如下。
1 | window.onmessage = function (e) { |
加强版的父窗口发送消息代码如下。
1 | var win = document.getElementsByTagName("iframe")[0].contentWindow; |
SessionStorage 同理。
WebSocket
WebSocket 是一种通信协议,使用ws://(非加密)
和wss://(加密)
作为协议前缀。该协议不实行同源策略,只要服务器支持,就可以通过它进行跨源通信。
下面是一个例子,浏览器发出的 WebSocket 请求的头信息(摘自维基百科)。
1 | GET /chat HTTP/1.1 |
上面代码中,有一个字段是Origin
,表示该请求的请求源(origin
),即发自哪个域名。
正是因为有了Origin
这个字段,所以 WebSocket 才没有实行同源策略。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,服务器就会做出如下回应。
1 | HTTP/1.1 101 Switching Protocols |
示例:
- Node 服务端:
1 | const WebSocket = require("ws"); |
- 页面 1:
1 | var ws = new WebSocket("ws://localhost:3006/ws", "suyp"); |
- 页面 2
1 | // 向父页面发送消息,来关闭弹窗 |
小结
跨域脚本 API 访问推荐做法:
- Nginx 代理到一起 (最好)
- 使用 postMessaget 通信 (挺好)