前端跨域问题及解决方案

@wengjq 2017-02-10 06:16:58发表于 wengjq/Blog 原理

1、同源策略

同源策略限制从一个源加载的文档或脚本如何与来自另一个源的资源进行交互。

一个源的定义:如果协议,端口(如果指定了一个)和主机对于两个页面是相同的,则两个页面具有相同的源。

下表给出了相对 http://store.company.com/dir/page.html 同源检测的示例:

URL 结果 原因
http://store.company.com/dir/inner/another.html 成功 同一域名
http://store.company.com/dir2/other.html 成功 同一域名下不同文件夹
https://store.company.com/secure.html 失败 不同的协议 ( https )
http://store.company.com:81/dir/etc.html 失败 不同的端口 ( 81 )
http://news.company.com/dir/other.html 失败 不同的主机 ( news )

2、主域相同的跨域

document.domain的场景只适用于不同子域的框架间的交互,及主域必须相同的不同源。

页面可能会更改其自己的来源,但有一些限制。脚本可以将 document.domain的值设置为其当前域或其当前域的超级域。如果将其设置为其当前域的超级域,则较短的域将用于后续原始检查。例如,假设文档中的一个脚本在 http://store.company.com/dir/other.html 执行以下语句:

 document.domain = "company.com";

这条语句执行之后,页面将会成功地通过对 http://company.com/dir/page.html 的同源检测。

注:浏览器单独保存端口号。任何的赋值操作,包括document.domain = document.domain都会以null值覆盖掉原来的端口号。因此,company.com:8080页面的脚本不能仅通过设置document.domain = "company.com"就能与company.com通信。赋值时必须带上端口号,以确保端口号不会为null。

(1) 在www.a.com/a.html中:
document.domain = 'a.com';
var ifr = document.createElement('iframe');
ifr.src =  'http://www.script.a.com/b.html';  
ifr.display = none;
document.body.appendChild(ifr);
ifr.onload = function(){ 
    var doc = ifr.contentDocument || ifr.contentWindow.document;                                                          
    ifr.onload = null;
};

(2) 在www.script.a.com/b.html中:
document.domain = 'a.com';//注意:使用document.domain允许子域安全访问其父域时,您需要设置document域在父域和子域中具有相同的值。这是必要的,即使这样做只是将父域设置回其原始值。否则可能会导致权限错误。这里都是a.com。

3、完全不同源的跨域(两个页面之间的通信)

3.1、通过location.hash跨域

假设域名a.com下的文件cs1.html要和jianshu.com域名下的cs2.html传递信息。
1、cs1.html首先创建自动创建一个隐藏的iframe,iframe的src指向jianshu.com域名下的cs2.html页面。
2、cs2.html响应请求后再将通过修改cs1.html的hash值来传递数据。
3、同时在cs1.html上加一个定时器,隔一段时间来判断location.hash的值有没有变化,一旦有变化则获取获取hash值。

注:由于两个页面不在同一个域下IE、Chrome不允许修改parent.location.hash的值,所以要借助于a.com域名下的一个代理iframe。

优点:1.可以解决域名完全不同的跨域。2.可以实现双向通讯。
缺点:location.hash会直接暴露在URL里,并且在一些浏览器里会产生历史记录,数据安全性不高也影响用户体验。另外由于URL大小的限制,支持传递的数据量也不大。有些浏览器不支持onhashchange事件,需要轮询来获知URL的变化。

3.2、通过window.name跨域

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

 window.name = data;//父窗口先打开一个子窗口,载入一个不同源的网页,该网页将信息写入。        
 location = 'http://parent.url.com/xxx.html';//接着,子窗口跳回一个与主窗口同域的网址。
 var data = document.getElementById('myFrame').contentWindow.name。//然后,主窗口就可以读取子窗口的window.name了。

如果是与iframe通信的场景就需要把iframe的src设置成当前域的一个页面地址。

3.3、通过window.postMessage跨域

HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。这个API为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。postMessage的兼容性如下:

Paste_Image.png
可以看到 Internet Explorer 8+, chrome,Firefox , Opera 和 Safari 都将支持这个功能。但是Internet Explorer 8和9以及Firefox 6.0和更低版本仅支持字符串作为postMessage的消息

var popup = window.open('http://bbb.com', 'title');//父窗口http://aaa.com向子窗口http://bbb.com发消息,调用postMessage方法。
popup.postMessage('Hello World!', 'http://bbb.com');

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

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

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

一个例子:

 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);  
 }

4、AJAX请求不同源的跨域

4.1、通过JSONP跨域

基本原理:网页通过添加一个<script>元素,向服务器请求JSON数据,这种做法不受同源政策限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。例子如下:

function todo(data){
  console.log('The author is: '+ data.name);
}
var script = document.createElement('script');
script.src = 'http://www.jianshu.com/author?callback=todo';//向服务器www.jianshu.com发出请求。注意,该请求的查询字符串有一个callback参数,用来指定回调函数的名字。
document.body.appendChild(script);
//服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。
todo({"name": "fewjq"});
//由于<script>元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了todo函数,该函数就会立即调用。作为参数的JSON数据被视为JavaScript对象。

优点:简单适用,老式浏览器全部支持,服务器改造小。不需要XMLHttpRequest或ActiveX的支持。
缺点:只支持GET请求。

4.2、通过WebSocket跨域

WebSocket是一种通信协议,使用ws://(非加密)和wss://(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。

4.3、通过CORS跨域

当一个资源请求一个其它域名的资源时会发起一个跨域HTTP请求(cross-origin HTTP request)。比如说,域名A的某 Web 应用通过标签引入了域名B的某图片资源,域名A的 Web 应用就会导致浏览器发起一个跨域 HTTP 请求。在当今的 Web 开发中,使用跨域 HTTP 请求加载各类资源(包括CSS、图片、JavaScript 脚本以及其它类资源),已经成为了一种普遍且流行的方式。

正如大家所知,出于安全考虑,浏览器会拦截跨域请求的返回结果。比如,使用XMLHttpRequest对象和Fetch发起 HTTP 请求就必须遵守“同源策略”。 具体而言,Web 应用程序通过XMLHttpRequest对象或Fetch能且只能向同域名的资源发起 HTTP 请求,而不能向任何其它域名发起请求。为了能开发出更强大、更丰富、更安全的Web应用程序,开发人员渴望着在不丢失安全的前提下,Web 应用技术能越来越强大、越来越丰富。比如,可以使用 XMLHttpRequest 发起跨站 HTTP 请求。

Paste_Image.png
隶属于 W3C 的 Web 应用工作组推荐了一种新的机制,即跨源资源共享(Cross-Origin Resource Sharing ) CORS。这种机制让Web应用服务器能支持跨站访问控制,从而使得安全地进行跨站数据传输成为可能。需要特别注意的是,这个规范是针对API容器的(比如说XMLHttpReques 或者 Fetch),以减轻跨域HTTP请求的风险。

跨源资源共享标准( cross-origin sharing standard) 使得以下场景可以使用跨站 HTTP 请求:

  • 1、使用 XMLHttpRequest 或 Fetch发起跨站 HTTP 请求。
  • 2、Web 字体 (CSS 中通过 @font-face 使用跨站字体资源),因此,网站就可以发布 TrueType 字体资源,并只允许已授权网站进行跨站调用。
  • 3、WebGL 贴图
  • 4、使用drawImage绘制
  • 5、Images/video 画面到canvas.
  • 6、样式表(使用 CSSOM)
  • 7、Scripts (for unmuted exceptions)

所有浏览器都支持该功能,IE浏览器不能低于IE10。通过 XMLHttpRequest 对象发起。但Internet Explorer 8 和 9 可以通过 XDomainRequest 对象来实现CORS。可以把CORS分为:简单请求、预请求和附带凭证信息的请求。

4.3.1、简单请求

所谓的简单,是指:
(1)只使用 GET, HEAD 或者 POST 请求方法。如果使用 POST 向服务器端传送数据,则数据类型(Content-Type)只能是 application/x-www-form-urlencoded, multipart/form-data 或 text/plain中的一种。
(2)不会使用自定义请求头(类似于 X-Modified 这种)。

一个例子:

//比如说,假如站点 http://foo.example 的网页应用想要访问 http://bar.other 的资源。以下的 JavaScript 代 
//码应该会在 foo.example 上执行:    
var invocation = new XMLHttpRequest();
var url = 'http://bar.other/resources/public-data/';
function callOtherDomain() {
  if(invocation) {    
    invocation.open('GET', url, true);
    invocation.onreadystatechange = handler;
    invocation.send(); 
  }
}

Paste_Image.png

//让我们看看,在这个场景中,浏览器会发送什么的请求到服务器,而服务器又会返回什么给浏览器:
GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 
Minefield/3.1b3pre
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Referer: http://foo.example/examples/access-control/simpleXSInvocation.html
Origin: http://foo.example //该请求来自于 http://foo.exmaple。
//以上是浏览器发送请求

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2.0.61 
Access-Control-Allow-Origin: * //这表明服务器接受来自任何站点的跨站请求。如果设置为http://foo.example。其它站点就不能跨站访问 http://bar.other 的资源了。
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml
//以上是服务器返回信息给浏览器

如上,通过使用 Origin 和 Access-Control-Allow-Origin 就可以完成最简单的跨站请求。不过服务器需要把 Access-Control-Allow-Origin 设置为 * 或者包含由 Origin 指明的站点。

4.3.2、预请求

不同于上面讨论的简单请求,“预请求”要求必须先发送一个 OPTIONS 请求给目的站点,来查明这个跨站请求对于目的站点是不是安全可接受的。这样做,是因为跨站请求可能会对目的站点的数据造成破坏。 当请求具备以下条件,就会被当成预请求处理:
(1)请求以 GET, HEAD 或者 POST 以外的方法发起请求。或者,使用 POST,但请求数据为 application/x-www-form-urlencoded, multipart/form-data 或者 text/plain 以外的数据类型。比如说,用 POST 发送数据类型为 application/xml 或者 text/xml 的 XML 数据的请求。
(2)使用自定义请求头(比如添加诸如 X-PINGOTHER)

一个例子:

var invocation = new XMLHttpRequest();
var url = 'http://bar.other/resources/post-here/';
var body = '{C}{C}{C}{C}{C}{C}{C}{C}{C}{C}Arun';
function callOtherDomain(){
  if(invocation){
    invocation.open('POST', url, true);
    invocation.setRequestHeader('X-PINGOTHER', 'pingpong');
    invocation.setRequestHeader('Content-Type', 'application/xml');
    invocation.onreadystatechange = handler;
    invocation.send(body); 
  }
}

如上,以 XMLHttpRequest 创建了一个 POST 请求,为该请求添加了一个自定义请求头(X-PINGOTHER: pingpong),并指定数据类型为 application/xml。所以,该请求是一个“预请求”形式的跨站请求。浏览器使用一个 OPTIONS 发送了一个“预请求”。Firefox 3.1 根据请求参数,决定需要发送一个“预请求”,来探明服务器端是否接受后续真正的请求。 OPTIONS 是 HTTP/1.1 里的方法,用来获取更多服务器端的信息,是一个不应该对服务器数据造成影响的方法。 随同 OPTIONS 请求,以下两个请求头一起被发送:

Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER

假设服务器成功响应返回部分信息如下:

Access-Control-Allow-Origin: http://foo.example //表明服务器允许http://foo.example的请求
Access-Control-Allow-Methods: POST, GET, OPTIONS //表明服务器可以接受POST, GET和 OPTIONS的请求方法
Access-Control-Allow-Headers: X-PINGOTHER //传递一个可接受的自定义请求头列表。服务器也需要设置一个与浏览器对应。否则会报 Request header field X-Requested-With is not allowed by Access-Control-Allow-Headers in preflight response 的错误
Access-Control-Max-Age: 1728000 //告诉浏览器,本次“预请求”的响应结果有效时间是多久。在上面的例子里,1728000秒代表着20天内,浏览器在处理针对该服务器的跨站请求,都可以无需再发送“预请求”,只需根据本次结果进行判断处理。

4.3.3、附带凭证信息的请求

XMLHttpRequest 和访问控制功能,最有趣的特性就是,发送凭证请求(HTTP Cookies和验证信息)的功能。一般而言,对于跨站请求,浏览器是不会发送凭证信息的。但如果将 XMLHttpRequest 的一个特殊标志位设置为true,浏览器就将允许该请求的发送。

一个例子:

//http://foo.example站点的脚本向http://bar.other站点发送一个GET请求,并设置了一个Cookies值。脚本代码如下:
var invocation = new XMLHttpRequest();
var url = 'http://bar.other/resources/credentialed-content/';
function callOtherDomain(){
  if(invocation) {
    invocation.open('GET', url, true);
    invocation.withCredentials = true;
    invocation.onreadystatechange = handler;
    invocation.send(); 
  }
}

如上,第七行代码将 XMLHttpRequest 的withCredentials标志设置为true,从而使得Cookies可以随着请求发送。因为这是一个简单的GET请求,所以浏览器不会发送一个“预请求”。但是,如果服务器端的响应中,如果没有返回Access-Control-Allow-Credentials: true的响应头,那么浏览器将不会把响应结果传递给发出请求的脚本程序,以保证信息的安全。

假设服务器成功响应返回部分信息如下:

Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Credentials: true
Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT

如果bar.other的响应头里没有Access-Control-Allow-Credentials:true,则响应会被忽略.。特别注意: 给一个带有withCredentials的请求发送响应的时候,服务器端必须指定允许请求的域名,不能使用“*”。上面这个例子中,如果响应头是这样的 Access-Control-Allow-Origin:* ,则响应会失败。在这个例子里,因为Access-Control-Allow-Origin的值是 http://foo.example 这个指定的请求域名,所以客户端把带有凭证信息的内容被返回给了客户端。另外注意,更多的cookie信息也被创建了。

CORS的优点:CORS支持所有类型的HTTP请求,是跨域HTTP请求的根本解决方案。