本文介绍由于浏览器同源策略(same-origin policy)造成的跨域问题,以及在开发、生产环境条件下的实践。其中大部分概念是来自于阮一峰老师的帖子。本人充当了一个搬砖的角色,做一个总结以及实践。
背景
随着前端MVVM模式的前端框架vue、react的普及,前后端分离的开发方式更加普遍,前端只需要通过RESTful api获取到后端返回的数据即可完成全部的页面逻辑。前后端分离开发,分离部署就会出现前后端服务分属不同【源】的情况,但由于浏览器的【同源策略】限制在实际开发和生产环境下前后端的交互出现各种问题。
浏览器同源策略(same-origin policy)
上文提到浏览器同源策略的存在给前后端分离开发方式造成了一些困扰,使正常的开发流程受阻,那么什么是同源策略呢?
通俗的讲就是:同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。
同源
同源是指三同:
- 协议相同
- 域名相同
- 端口相同
举例来说,http://www.example.com/dir/page.html这个网址,协议是http://,域名是www.example.com,端口是80(默认端口可以省略)。它的同源情况如下。
- www.example.com/dir2/other.…
- example.com/dir/other.h…
- v2.www.example.com/dir/other.h…
- www.example.com:81/dir/other.h…
- www.example.com/dir/other.h…
同源策略的主要应用场景
同源政策的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。
设想这样一种情况:A网站是一家银行,用户登录以后,又去浏览其他网站。如果其他网站可以读取A网站的Cookie,会发生什么?
很显然,如果Cookie包含隐私(比如存款总额),这些信息就会泄漏。更可怕的是,Cookie往往用来保存用户的登录状态,如果用户没有退出登录,其他网站就可以冒充用户,为所欲为。因为浏览器同时还规定,提交表单不受同源政策的限制。
由此可见,"同源策略"是必需的,否则Cookie可以共享,互联网就毫无安全可言了。
影响范围(跨域问题)及规避方法
同源策略是为了保证网络环境下基本的安全,如果违背该策略则会触发跨域问题
影响范围
通过介绍出于安全考虑的同源限制很有必要,合理的用途也会受限制。如果非同源,会有三种行为会受到限制
Cookie、LocalStorage和IndexDB无法读取DOM无法获得AJAX请求不能发送
规避方法
在跨域的情况,针对以上三种行为的限制,会有不同的应对方式,具体方式如下
不同窗口
两个不同窗口(指两个不同页签,不同浏览器窗口)可以突破跨域问题的途径只有在两个页面的一级域名相同,二级域名不同的情况下浏览器允许通过设置document.domain来共享Cookie。
举例来说,A网页是http://w1.example.com/a.html,B网页是http://w2.example.com/b.html,那么只要设置相同的document.domain,两个网页就可以共享Cookie。
document.domain = 'example.com';
现在,A网页通过脚本设置一个Cookie:
document.cookie = 'test1=hello';
那么,B网页就可以读到这个Cookie:
const allCookie = document.cookie;
// test1=hello
注意,这种方法只适用于Cookie和iframe窗口,LocalStorage和IndexDB无法通过这种方法,规避同源政策,而要使用下文介绍的PostMessage API。
另外,服务器也可以在设置Cookie的时候,指定Cookie的所属域名为一级域名,比如.example.com。
Set-Cookie: key=value; domain=.example.com; path=/
这样的话,二级域名和三级域名不用做任何设置,都可以读取这个Cookie。
不同窗口下无论如何也不能获取对方的
DOM元素的
同一窗口
同一窗口(指同一个浏览器窗口,通过iframe和window.open方法打开的窗口)下的两个源不同的话,它们是不能与父窗口通信的。
当然通过父窗口是无法获取到iframe子窗口的DOM元素的:
document.getElementById("iframeId").contentWindow.document;
// Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.
上面直接获取子窗口的DOM时由于跨域而导致报错,当然子窗口也不能直接获取父窗口的DOM:
window.parent.document.body
// 报错
如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain属性,就可以规避同源政策,拿到DOM。
对于完全不同源的网站,这种情况下有三种方法,可以解决跨域窗口的通信问题:
- 片段识别符(
fragment identifier) window.name- 跨文档通信API(
Cross-document messaging)
片段识别符(fragment identifier)
片段标识符(fragment identifier)指的是,URL的#号后面的部分,比如http://example.com/x.html#fragment的#fragment。如果只是改变片段标识符,页面不会重新刷新。
父窗口可以把信息,写入子窗口的片段标识符:
const src = `${originUrl}#${data}`;
document.getElementById('iframeId').src = src;
子窗口通过监听hashchange事件得到通知:
window.onhashchange = () => {
const message = window.location.hash;
// ...
}
同样的,子窗口也可以改变父窗口的片段标识符:
parent.location.href = `${target}#${hash}`
window.name
浏览器窗口有window.name属性。这个属性可以直接跨域获取到,只要在同一个窗口里,前一个网页设置了这个属性,后一个网页可以读取它。
父窗口先打开一个子窗口,载入一个不同源的网页,该网页将信息写入window.name属性:
window.name = data
接着,子窗口跳回一个与主窗口同域的网址:
location = 'http://parent.url.com/xxx.html';
然后,主窗口就可以读取子窗口的window.name了:
const data = document.getElementById('iframeId').contentWindow.name;
这种方法的优点是,window.name容量很大,可以放置非常长的字符串;缺点是必须监听子窗口window.name属性的变化,影响网页性能。
跨文档通信API(Cross-document messaging)
以上两种方法均属于hack的方式,HTML5为了解决这个问题,引入了一个全新的API:跨文档通信API(Cross-document messaging)。
这个API为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。
举例来说,父窗口http://aaa.com向子窗口http://bbb.com发消息,调用postMessage方法就可以了:
const popup = window.open('http://bbb.com', 'title');
popup.postMessage("Hello Word!", 'http://bbb.com');
postMessage方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即"协议 + 域名 + 端口"。也可以设为*,表示不限制域名,向所有窗口发送。
子窗口向父窗口发送消息的写法类似:
window.opener.postMessage('Nice to see you', 'http://aaa.com');
父窗口和子窗口都可以通过message事件,监听对方的消息:
window.addEventListener('message', function(e) {
console.log(e.data);
},false);
message事件的事件对象event,提供以下三个属性:
event.source:发送消息的窗口event.origin: 消息发向的网址event.data: 消息内容
下面的例子是,子窗口通过event.source属性引用父窗口,然后发送消息:
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
event.source.postMessage('Nice to see you!', '*');
}
event.origin属性可以过滤不是发给本窗口的消息:
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
if (event.origin !== 'http://aaa.com') return;
if (event.data === 'Hello World') {
event.source.postMessage('Hello', event.origin);
} else {
console.log(event.data);
}
}
当然如果可以通过
postMessage父子窗口进行通信,那么父子窗口就可以互相调用对方的资源比如LocalStorage、IndexDB来实现跨域规避。
AJAX(重点)
针对同源策略对AJAX请求的限制有以下几种解决方式
JSONPWebSocket- 跨域资源共享
CORS - 代理
Proxy
其中设置CORS是比较常用的解决方式
JSONP
JSONP的原理就是利用img,script等标签的src属性不受同源策略的限制向服务器发送请求的方式实现跨域的一种方式,服务器收到请求后,将数据通过约定的一个回调函数的参数回传回来。设置script标签type="text/script"之后,服务器返回的数据可以直接在全局上下文执行的一种与服务器交互的方式。
javascript动态创建script标签,设置src来发送跨源网址的请求:// 动态添加script标签 function addScriptTag(src) { var script = document.createElement('script'); script.setAttribute("type","text/javascript"); script.src = src; document.body.appendChild(script); } window.onload = function () { // 地址中指定约定好的回调函数名称callback=foo addScriptTag('http://example.com/ip?callback=foo'); } // 约定好的回调函数在前端预先定义好 function foo(data) { console.log('Your public IP address is: ' + data.ip); };- 服务端收到
http://example.com/ip?callback=foo请求后,将数据放在回调函数的参数位置返回:foo({ "ip": "x.x.x.x" }) - 服务端返回的数据会被浏览器立即执行,由于前端预定义好了指定的函数,故可以直接执行打印出
ip。
以上几个步骤完成了一个简单JSONP交互的过程,很显然它可以有效的绕过同源策略实现前后端数据交互,但从现代前端的角度看只不过是一种hack的方式,存在着很多问题:
- 该方式只支持
GET请求,当然这里也提一下,可以通过空iframe和form表单实现POST这里就不多赘述 - 该方式略显繁琐,并不能适应现代应用的多交互的应用场景
- 当然这种方式虽然有效绕过了同源策略但也会暴露出安全问题
WebSocket
WebSocket是一种通信协议,使用ws://(非加密)和wss://(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。
跨域资源共享(CORS)(重点)
CORS是跨源资源共享(Cross-Origin Resource Sharing)的缩写。它是W3C标准,是跨源AJAX请求的根本解决方法。相比JSONP只能发GET请求,CORS允许任何类型的请求。
CORS允许浏览器跨域发送XMLHttpRequest请求,来克服AJAX的同源限制CORS需要浏览器和服务端同时支持,IE浏览器不能低于IE10CORS整个过程对用户和开发来说是无感的,设置完成后,浏览器一旦发现AJAX请求跨域,就会自动添加一些附加的头信息,当然有时会多发送一次附加的【预检】请求:-
简单请求(
simple request)只要同时满足以下条件,就属于简单请求:
- 请求是以下三种方法之一:
- HEAD
- GET
- POST
- HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
对于满足以上条件的简单请求,浏览器会在请求头中自动添加
Origin字段来指定发送跨域请求的源地址:服务端根据
Origin指定的源来决定是否同意该次请求:通过上面截图可以看到一个正常的
CORS返回包含了一些附件的返回头信息:-
Access-Control-Allow-Origin: 与Origin字段对应,如果Origin不在服务的许可范围内,则会返回一个正常的HTTP回应(不包含Access-Control-Allow-Origin),浏览器发现不存在合格头信息则会抛出一个错误信息:当然,服务端可以设置请求时的
Origin字段值,或者干脆给一个*来标识接受任意域名的请求。 -
Access-Control-Allow-Credentials: 该字段不是必须,表示是否允许发送Cookie。默认情况下Cookie是不包括在CORS请求中的。设为true则明确表示Cookie可以包含在请求中。这里同时需要浏览器端代码进行一个设置withCredentials = true来明确发送的请求中可以携带Cookie,此时服务端会自动增加该头信息Access-Control-Allow-Credentials: true// 原生XMLHttpRequest const xhr = new XMLHttpRequest(); xhr.withCredentials = true; // umi-request import { extend } from 'umi-request'; const request = extend({ // other config... credentials: 'include', // 默认请求是否带上cookie }); // axios import axios from 'axios'; const instance = axios.create({ // `withCredentials` 表示跨域请求时是否需要使用凭证 withCredentials: true, // default false })如果需要发送
Cookie,Access-Control-Allow-Origin则不能设置*,需要设置与请求的网页一直的域名。 -
Access-Control-Expose-Headers: 在AJAX请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定:// 后端指定可以额外获取一下字段 Access-Control-Expose-Headers: Content-disposition,Content-Type,Access-Token,Authorization // 在返回blob信息是有该字段指定文件名称 Content-disposition: attachment;filename=%E6%B6%89%E6%8B%90%E6%A1%88%E4%BB%B6.xlsx那么我可以在后端返回
Blob文件流时获取Content-disposition中的文件名来进行下载:import { extend } from 'umi-request'; const request = extend({ credentials: 'include', // 默认请求是否带上cookie // 设置可以使用getResponse() getResponse: true, }); request.use(async (ctx, next) => { await next(); const { data: resData, response } = ctx.res; if (!resData.code) { // 如果是blob则通过get获取对应的文件名称 ctx.res = { blob: response.blob(), filename: decodeURIComponent( // 示例:attachment;filename=%E6%B6%89%E6%8B%90%E6%A1%88%E4%BB%B6.xlsx (response.headers.get('Content-disposition') || '').split('=')[1] || '未命名', ), }; return; } } -
Access-Control-Allow-Methods:该字段表明服务器支持的所有跨域请求的方法,返回的是所有支持的方法而不是当前浏览器请求的的方法,同时也是为了避免下面提到的非简单请求时多次的【预检】请求。Access-Control-Allow-Methods: OPTIONS,HEAD,DELETE,GET,PUT,POST -
Access-Control-Allow-Headers:表明服务器支持的所有头信息字段:Access-Control-Allow-Headers: Content-Type,Access-Token,Authorization
- 请求是以下三种方法之一:
-
非简单请求(
not-so-simple request)上面定义了简单请求的定义,那么不满足简单请求的情况则是非简单请求。是指对服务器有特殊要求的请求,比如
PUT、DELETE,或者Content-Type是application/json等等情况。浏览器在认定非简单请求后在正式通信之前,会发送一次
OPTIONS请求,称为【预检】请求(preflight)。浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些
HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。上面是一个预检请求的请求头,重点是
Access-Control-Request-Headers和Access-Control-Request-Method两个头信息Access-Control-Request-Headers:表明请求额外发送的头信息字段,上面截图中是Content-TypeAccess-Control-Request-Method:表明本次预检请求会用到哪些HTTP方法,上面截图是POST上图是一个预检请求的请求头,大致的信息和简单请求一直,同时也可以增加一个
Access-Control-Max-Age(字段可选)信息来表明本次预检的有效期,有效期内无需再次预检。
预检请求通过后,浏览器会自动发送一个正常的请求,其请求头和响应头和简单请求一致。
-
一个
CORS请求无论是简单请求还是非简单请求,大致的思路是通过Origin来确定该跨域请求是否可信,设置可信的源信息,头信息,方法信息校验通过后即可进行正常的HTTP跨域请求交互
代理(Proxy)
- 代理(
Proxy)是指通过设置一个代理服务器对前端发送的请求进行转发,以达到绕过同源策略的目的。 - 前端通过部署到
Nginx,Caddy,nodejs等静态服务,该服务再通过配置代理转发到目的服务器,以实现数据跨域交互。 - 该方法在前端开发时比较常用,通过配置
webpack的devServer来实现代理,让开发更顺畅。 Nginx代理实现示意:
通过以上配置就实现了一个代理转发,当然可以通过http { location / { proxy_pass http://x.x.x.x:8005 } }location定位到任何你想转发的路径,或者配置其他代理选项
开发时CORS请求的一些报错信息及解读
上面用很大篇幅介绍什么是同源策略,什么是跨域,在开发和生产环境中怎么解决这些问题,最后我们找到了一个通用的解决方式:
CORS。
常见的CORS报错信息:
-
CORS正常请求校验不通过:通常为
Origin和Access-Control-Allow-Origin未设置或者不一致,还有一种情况就是前端请求时后端服务正在重启,会报CORS error -
CORS正常请求校验Headers不通过:通常为非简单请求时未设置对应的自定义
Header或者不一致造成的,需要后端设置对应的字段,上面这个报错是指authorization这个字段校验不通过触发同源策略 -
CORS预检请求校验不通过:
报错原因:
- 上文针对简单请求,非简单请求,以及预检请求的响应头信息进行了分析,通常都是前后端在实现
CORS时关键设置缺失造成的浏览器校验不通过 - 通常涉及的字段包括
Origin,Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Credentials,Access-Control-Allow-Headers这些关键校验信息的原因
CORS解决方法
上面零星提到了对CORS的设置和配置,这里贴出完整的示例:
-
后端(java)
从上图可以看出,大致是定义一个类似
Filter来统一处理请求:- 根据来时的
Origin去设置Access-Control-Allow-Origin,如果没有Origin则设置* - 设置
Access-Control-Allow-Credentials为true - 设置
Access-Control-Allow-Methods为OPTIONS,HEAD,DELETE,GET,PUT,POST - 设置
Access-Control-Allow-Headers为Content-Type,Access-Token,Authorization - 设置
Access-Control-Expose-Headers为Content-disposition,Content-Type,Access-Token,Authorization - 发现是
OPTIONS预检请求直接放过,正常返回
- 根据来时的
-
前端(javascript)
前端配置相对简单,上图以
umi-request配置为例,直接设置是否携带Cookie和是否可以获取response头字段
通过以上配置即可以顺畅完成开发和生产环境的AJAX跨域交互,当然也有例外,比如在实现Blob二进制流数据下载文件时,仍然会遇到某些接口报CORS error,其原因是后端工程师避免文件缓存问题将头信息重置,导致丢失CORS相关头信息所致,故需要后端工程师在处理这种接口时将对应的头信息过滤掉再重置即可解决报错不能下载文件的问题。
总结
本文深入探究了同源策略,跨域问题以及解决方案,提到了由于同源策略被限制的数据共享和交互的方式,也给出了对应的解决策略。随着浏览器,前端,后端技术的发展一些新的方式会更普遍,比如利用跨域资源共享CORS来解决前后端数据交互问题,来代替一些利用各种机制hack实现的解决跨域问题方案。CORS更灵活,更方便,但我们还是频频遇到CORS报错问题,这里找到了关于火狐浏览器的CORS errors相关信息。通过这份报错信息和上面介绍到的CORS交互过程来解决CORS报错信息很有帮助,只要把握住请求的请求头信息就可以从根源上找到问题所在。
搬砖
- w3c-same-origin policy
- segmentfault-不要再问我跨域的问题了
- 阮一峰-浏览器同源政策及其规避方法
- 阮一峰-跨域资源共享 CORS 详解
- MDN-same-origin policy
- MDN-CORS errors
- 张旭鑫-canvas中的跨域