跨域的原理及解决方案

什么是跨域

之所以有跨域(Cross-Origin, 也译作跨源)这种操作方式,是为了解决浏览器同源策略。同源,即协议相同、域名相同及端口相同。同源策略最早被网景引入是为了保证用户信息的安全,防止恶意的网站窃取数据。最初非同源限制是cookie,现在包括dom和xhr及fetch请求都不能跨域访问。

不受同源策略限制的情况也有很多,如:

  1. <script src="..."></script>标签嵌入跨域脚本;
  2. <link rel="stylesheet" href="...">标签嵌入CSS;
  3. <img>嵌入图片;
  4. <video><audio>嵌入多媒体资源;
  5. <object>, <embed><applet>的插件;
  6. @font-face引入的字体;
  7. <frame><iframe>载入的任何资源.

通过这些“例外”情况,就给了程序员们可乘之机,涌现出了许多奇技淫巧,这类跨域的解决方案姑且称之为hack流。

与此同时,w3c听取了程序员们对于跨域请求的渴望,推出了cors标准,通过设置服务器以达到ajax的跨域请求,html5也适时推出了postMessage api以满足跨域通信。这种官方开门允许跨域的方法,我们姑且称之为正统流。

跨域访问的解决方案

如上文所言,从原理上,跨域的解决方案可以大概分为正统流和hack流两大类。下面具体分别来介绍各种解决方法的实现。
Classification

分类

Hack流

IMG Ping——利用图片的跨域特性

我们每天浏览很多的网页,有时候可能会发现某些图片(<img>)是来自其他网站的,其实你看到的就是一种跨域,由于图片不受“同源策略”限制,我们就可以利用图片进行跨域了。我们将图片的src属性指向请求的地址,通过监听load和error事件,就能知道响应什么时候接受了,响应的数据可以是任意内容,但通常是204响应(No content 没有响应体)。图像ping的例子如下:

1
2
3
4
5
6
7
8
var btn = document.querySelector("#start-ping"); 
btn.onclick = function(){
var img = new Image();
img.onload = img.onerror = function(obj){
document.querySelector("#result").innerHTML = "finished";
};
img.src = "http://localhost:3000/img?r="+Math.random();
};

服务器端代码:

1
2
3
router.get('/img', function(req, res, next) { 
res.send('success!');
});

然而在实际使用中会发现,无法获取相应文本。

优点:兼容性好;缺点:只支持Get请求且无法获取相应。

JSONP——利用script标签的跨域特性

JSONP是JSON with padding(填充式JSON)的简写,是应用JSON的一种方法,看起来和JSON差不多,只不过是被包含在函数调用中的JSON。例如:

1
callback({"key":"val"});

JSONP由两部分组成:回调函数和数据,回调函数是响应到来时应该在页面中调用的函数,而数据是传入回调函数中的JSON数据(服务器填充的)。下面就是一个典型的JSONP请求:

1
http://example.com/jsonp?callback=handleResponse

JSONP也是不受“同源策略”限制的,原因和图片ping是一样的,<script>标签也可以跨域,因此我们可以通过利用JONP来动态创建<script>,并将其src指向一个跨域的URL,就可以完成和跨域得服务器之间的通信了。下面就来看一个例子:

1
2
3
4
5
6
7
8
9
var btn2 = document.querySelector("#start-jsonp"); 
btn2.onclick = function(){
var script = document.createElement("script");
script.src = "http://localhost:3000/jsonp";
document.body.insertBefore(script, document.body.firstChild);
};
function pagefunc(num){
document.querySelector("#result2").innerHTML = "我从服务器获得了一个随机数:"+num;
}

服务器代码:

1
2
3
router.get('/jsonp', function(req, res, next) {
res.send('pagefunc(' + Math.random() + ')');
});

JSONP是非常简单易用的,与图像ping相比,优点就是能直接访问响应文本,能够在服务器与客户端建立双向通信。但是JSONP也是有缺点的:JSONP直接从其他域加载代码执行,如果其他域不安全,可能会在响应中夹带一些恶意代码。其次,要确定JSONP请求是否失败并不容易,HTML5为<script>增加了onerror方法,但是目前支持度还不是很好。

优点:简单易用,可以获取响应文本;缺点:只能get,不易获取错误。

iframe实现跨域通信

iframe的src可以指向不同域的地址,如果我们新建一个iframe元素将其指向所需跨域请求的地址是否就可以实现跨域通信了呢?然而事实是,iframe跨域的话将会取不到其dom元素。幸好前辈程序员们孜孜不倦的追求中,从iframe的一些特性中找到了空子可钻。

端口协议主域相同、子域不同

对于主域相同而子域不同的例子,可以通过设置document.domain的办法来解决。具体的做法是可以在http://www.a.com/a.html和http://script.a.com/b.html两个文件中分别加上document.domain = ‘a.com’;然后通过a.html文件中创建一个iframe,去控制iframe的contentDocument,这样两个js文件之间就可以“交互”了。当然这种办法只能解决主域相同而二级域名不同的情况,如果你异想天开的把script.a.com的domian设为alibaba.com的话,就会报错了。

www.a.com上的a.html:

1
2
3
4
5
6
7
8
9
10
document.domain = 'a.com';
var ifr = document.createElement('iframe');
ifr.src = 'http://script.a.com/b.html';
ifr.style.display = 'none';
document.body.appendChild(ifr);
ifr.onload = function(){
var doc = ifr.contentDocument || ifr.contentWindow.document;
// 在这里操纵b.html
alert(doc.getElementsByTagName("h1")[0].childNodes[0].nodeValue);
};

script.a.com上的b.html:

1
document.domain = 'a.com';

子域也不同

常用的有两种方法:利用location.hash改变页面不刷新的特性或者window.name在src改变的情况下不发生改变的特性进行跨域通信。

location.hash

假设域名a.com下的文件a.html要和b.com域名下的b.html传递信息。

  1. a.html首先创建自动创建一个隐藏的iframe,iframe的src指向b.com域名下的b.html页面;
  2. b.html响应请求后再将通过修改a.html的hash值来传递数据。由于两个页面不在同一个域下IE、Chrome不允许修改parent.location.hash的值,所以要借助于a.com域名下的一个代理iframe;
  3. 同时在a.html上加一个定时器(支持onhashchange事件的话可以监听该事件),隔一段时间来判断location.hash的值有没有变化,一旦有变化则获取获取hash值。

a.com/a.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function startRequest(){
var ifr = document.createElement('iframe');
ifr.style.display = 'none';
ifr.src = 'http://www.b.com/b.html#do';
document.body.appendChild(ifr);
}
startRequest();
function checkHash() {
try {
var data = location.hash ? location.hash.substring(1) : '';
if (console.log) {
console.log('data: '+data);
}
} catch(e) {};
}
setInterval(checkHash, 2000);

b.com/b.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//模拟一个简单的参数处理操作
switch(location.hash){
case '#do':
callBack();
break;
case '#did':
//do something……
break;
}

function callBack(){
try {
parent.location.hash = 'somedata';
} catch (e) {
// ie、chrome的安全机制无法修改parent.location.hash,
// 所以要利用一个中间的b.com域下的代理iframe
var ifrproxy = document.createElement('iframe');
ifrproxy.style.display = 'none';
ifrproxy.src = 'http://a.com/c.html#somedata'; // 注意该文件在"a.com"域下
document.body.appendChild(ifrproxy);
}
}

a.com/c.html:

1
parent.parent.location.hash = self.location.hash.substring(1);

优点:1.可以解决域名完全不同的跨域。2.可以实现双向通讯;

缺点:location.hash会直接暴露在URL里,并且在一些浏览器里会产生历史记录,数据安全性不高也影响用户体验。另外由于URL大小的限制,支持传递的数据量也不大。有些浏览器不支持hashchange事件,需要轮询来获知URL的变化。

window.name

window对象有个name属性,该属性有个特征:即在一个窗口(window)的生命周期内,窗口载入的所有的页面都是共享一个window.name的,每个页面对window.name都有读写的权限,window.name是持久存在一个窗口载入过的所有页面中的。window.name属性的神奇之处在于name 值在不同的页面(甚至不同域名)加载后依旧存在(如果没修改则值不会变化),并且可以支持非常长的 name 值(2MB)。

跨域解决方案似乎可以呼之欲出了,假设a.com域下的a.html页面请求远端服务器的数据,我们在该页面下新建一个iframe标签,该iframe的src属性指向b.com/b.com(利用iframe标签的跨域能力),b.com/b.html文件里设置好window.name的值(也就是该iframe的contentWindow的name值),然后在a.com/a.html里读取该iframe的window.name值,一切似乎水到渠成,代码如下:

a.com/a.html:

1
2
3
4
5
6
iframe = document.createElement('iframe'),
iframe.src = 'http://b.com/b.html';
document.body.appendChild(iframe);
iframe.onload = function() {
console.log(iframe.contentWindow.name)
};

b.com/b.html:

1
window.name = "{'key':'val'}";

然而实际使用的时候发现,会报错:Protocals, domains and ports must match. 所以还是跨域了……

为什么会这样,因为如最开始所说,如果页面与iframe的src不同源,则无法取该框架的信息。于是我们想到,既然window.name有不变性,那么我们只要载入跨域src且设置window.name信息之后换个src去指定,问题也许迎刃而解?

新建空白页面a.com/c.html,并且修改a.com/a.html:

1
2
3
4
5
6
7
iframe = document.createElement('iframe'),
iframe.src = 'b.com/b.html';
document.body.appendChild(iframe);
iframe.onload = function() {
iframe.src = 'http://a.com/c.html';
console.log(iframe.contentWindow.name)
};

实际使用过程中,确实可以取到window.name的值,然而iframe的load事件绑定了修改src的方法,于是load事件就被持续触发了,aka 整个iframe不断的刷新……继续修改a.com/a.html的代码以期完美:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
iframe = document.createElement('iframe');
iframe.style.display = 'none';
var state = 0;

iframe.onload = function() {
if(state === 1) {
var data = JSON.parse(iframe.contentWindow.name);
console.log(data);
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
} else if(state === 0) {
state = 1;
iframe.contentWindow.location = 'http://a.com/c.html';
}
};

iframe.src = 'http://b.com/b.html';
document.body.appendChild(iframe);

已经不复存在环境的方法——ie6的bug, window.navigator

IE6的bug,父页面和子页面都可以访问window.navigator这个对象,在navigator上添加属性或方法可以共享。因为现在没有IE6环境,此处不再赘述。

正统流

原本为保证用户安全而引入的同源策略,却限制了开发人员本应有的跨域需求。cors和postMessage适时推出,一个从服务端一个从前端提出了各自的跨域请求解决思路。

CORS——跨域资源共享

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10

因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。

有了CORS,没有跨域通信能力的XHR及Fetch终于也可以跨域了。从前端实现来看,和同源ajax请求并无差别。

跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。

接下来的内容将讨论相关场景,并剖析该机制所涉及的 HTTP 首部字段。

这里,我们使用三个场景来解释跨域资源共享机制的工作原理。这些例子都使用 XMLHttpRequest 对象。

简单请求

Simple Request

某些请求不会触发 CORS 预检请求。称之为“简单请求”,请注意,该术语并不属于 Fetch (其中定义了 CORS)规范。若请求满足所有下述条件,则该请求可视为“简单请求”:

  • 使用下列方法之一:
    1. GET
    2. HEAD
    3. POST
      1. Content-Type :
      2. //注:仅当POST方法的Content-Type值等于下列之一才算作简单请求
        1. text/plain
        2. multipart/form-data
        3. application/x-www-form-urlencoded
  • Fetch 规范定义了对 CORS 安全的首部字段集合,不得人为设置该集合之外的其他首部字段。该集合为:
    1. Accept
    2. Accept-Language
    3. Content-Language
    4. Content-Type (需要注意额外的限制)
    5. DPR
    6. Downlink
    7. Save-Data
    8. Viewport-Width
    9. Width

对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。

下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个Origin字段。

1
2
3
4
5
6
GET /cors HTTP/1.1
Origin: http://a.com
Host: b.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequest的onerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。

如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。

1
2
3
4
Access-Control-Allow-Origin: http://a.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

上面的头信息之中,有三个与CORS请求相关的字段,都以 Access-Control- 开头:

字段名 是否必须 类型 含义
Access-Control-Allow-Origin 请求时Origin字段的值/* 接受特定来源的请求,*为任意来源
Access-Control-Allow-Credentials 布尔值 是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。
Access-Control-Expose-Headers - CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。

由于CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定Access-Control-Allow-Credentials字段。

1
Access-Control-Allow-Credentials: true

另一方面,开发者必须在AJAX请求中打开withCredentials属性。

1
2
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。

但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,开发者可以在请求中显式关闭withCredentials。

需要注意的是,如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传。

预检请求

Preview Request
与前述简单请求不同,“需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。”预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

当请求满足下述任一条件时,即应首先发送预检请求:

  • 使用了下面任一 HTTP 方法:
    1. PUT
    2. DELETE
    3. CONNECT
    4. OPTIONS
    5. TRACE
    6. PATCH
  • 人为设置了对 CORS 安全的首部字段集合之外的其他首部字段。该集合为:
    1. Accept
    2. Accept-Language
    3. Content-Language
    4. Content-Type (but note the additional requirements below)
    5. DPR
    6. Downlink
    7. Save-Data
    8. Viewport-Width
    9. Width
  • Content-Type 的值不属于下列之一:
    1. application/x-www-form-urlencoded
    2. multipart/form-data
    3. text/plain

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

下面是一个预检请求的HTTP头信息示例:

1
2
3
4
5
6
7
8
OPTIONS /cors HTTP/1.1
Origin: http://a.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: b.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

除了Origin字段,”预检”请求的头信息包括两个特殊字段:

字段名 是否必须 含义
Access-Control-Request-Method 列出浏览器的CORS请求会用到哪些HTTP方法
Access-Control-Request-Headers 该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段

服务器收到”预检”请求以后,检查了Origin、Access-Control-Request-Method和Access-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应:

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://a.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

而如果服务器否定了”预检”请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。报错信息如下:

1
2
XMLHttpRequest cannot load http://b.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

一旦服务器通过了”预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。

CORS与JSONP的使用目的相同,但是比JSONP更强大。

优点:JSONP只支持GET请求,CORS支持所有类型的HTTP请求。

缺点:JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据。

postMessage——跨文档通信

在hack流中我们了解到,iframe的src如果和父页面不同源,那么父页面将取不到该框架的信息。HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。这个API为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。Internet Explorer 8+, chrome,Firefox , Opera 和 Safari 都支持这个功能。但是Internet Explorer 8和9以及Firefox 6.0和更低版本仅支持字符串作为postMessage的消息。

postMessage方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即”协议 + 域名 + 端口”。也可以设为*,表示不限制域名,向所有窗口发送。

父窗口和子窗口都可以通过message事件,监听对方的消息。message事件的事件对象event,提供以下三个属性:

  1. event.source: 发送消息的窗口;
  2. event.origin: 消息发向的网址;
  3. event.data: 消息内容.

一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var onmessage = function (event) { 
var data = event.data;//消息
var origin = event.origin;//消息来源地址
var source = event.source;//源Window对象
if(origin == "http://www.aaa.com"){
console.log(data);//hello world!
}
source.postMessage('Nice to see you!', '*');
};
if (typeof window.addEventListener != 'undefined') {
window.addEventListener('message', onmessage, false);
} else if (typeof window.attachEvent != 'undefined') {
//ie
window.attachEvent('onmessage', onmessage);
}

其他

时代眼泪——XDomainRequest

XDomainRequest是在IE8和IE9上的HTTP access control (CORS) 的实现,在IE10中被 包含CORS的XMLHttpRequest 取代了,该接口可以发送GET和POST请求。

语法

1
var xdr = new XDomainRequest();

返回XDomainRequest的实例,该实例可以被用来生成或管理请求。

XDomainRequest对象构成

构成 名称 含义
属性 timeout 获取或设置请求的过期时间
  responseText 以字符串形式获取响应体
方法 open() 根据指定的方法(GET或POST)和URL, 打开请求
  send() 发送请求, POST的数据会在该方法中被指定
  abort() 中止请求
事件处理程序 onprogress 当请求中发送方法和onload事件中有进展时的处理程序
  ontimeout 当请求超时时的事件处理程序
  onerror 当请求发生错误时的处理程序
  onload 当服务器端的响应被完整接收时的处理程序

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
if(window.XDomainRequest){
var xdr = new XDomainRequest();

xdr.open("get", "http://example.com/api/method");

xdr.onprogress = function () {
//Progress
};

xdr.ontimeout = function () {
//Timeout
};

xdr.onerror = function () {
//Error Occured
};

xdr.onload = function() {
//success(xdr.responseText);
}

setTimeout(function () {
xdr.send();
}, 0);
}

安全

XDomainRequest为了确保安全构建,采用了多种方法。

  • 安全协议源必须匹配请求的URL。(http到http,https到https)。如果不匹配,请求会报“拒绝访问”的错误。
  • 被请求的URL的服务器必须带有 设置为(“*”)或包含了请求方的Access-Control-Allow-Origin的头部。
开发专用——nginx反向代理

nginx反向代理的一个典型应用是均衡负载,然而其特性让我们在开发的时候可以方便的进行跨域设置。开发过程中一个典型的场景是,api已经上线,但是并没有设置cors,或者小作坊开发的时候,api服务在别人的电脑上也是开发状态,均不允许跨域调用。我们的应用上线之后发起的请求是同源的,然而在开发的时候是跨域的,apparently。这个时候也可以设置nginx反向代理实现跨域请求。

如果我们在a.com/a.html里ajax请求b.com/b?param=1, 必然会遇到跨域问题,如果我们打开nginx.conf配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
server{
listen 8000;
location / {
root html;
index index.html index.htm;
}
location /b {
rewrite ^/b/?$ /$1 break;
includeuwsgi_params;
proxy_pass http://b.com/b
}
}

总结

Extra

CSP——内容安全策略

坚持原创技术分享,您的支持将鼓励我继续创作!