同源策略到前端跨域解决方案

同源策略

同源策略 (Same-Origin Policy) 最早由 Netscape 公司提出, 所谓同源就是要求, 域名, 协议, 端口相同. 非同源的脚本不能访问或者操作其他域的页面对象(如DOM等). 作为著名的安全策略, 虽然它只是一个规范, 并不强制要求, 但现在所有支持 javaScript 的浏览器都会使用这个策略. 以至于该策略成为浏览器最核心最基本的安全功能, 如果缺少了同源策略, web的安全将无从谈起.

网上的一个栗子:同源检测的示例:

URL结果原因

成功

同一域名

成功

同一域名下不同文件夹

失败

不同的协议 ( https )

失败

不同的端口 ( 81 )

失败

不同的主机 ( news )

同源策略要求三同:

  • 同域:同域即host相同, 顶级域名, 一级域名, 二级域名, 三级域名等必须相同, 且域名不能与 ip 对应;

  • 同协议:同协议要求, http与https协议必须保持一致;

  • 同端口:同端口要求, 端口号必须相同.

在浏览器中,<script>、<img>、<iframe>、<link>等标签都可以加载跨域资源,而不受同源限制,但浏览器限制了JavaScript的权限使其不能读、写加载的内容。

跨域访问

关于解决跨域访问的方法, 一共收集到以下几种,

  • CROS

  • 取消浏览器安全校验,

  • JSONP,实际操作过,

  • 其他的方法来源于网络,并没有验证。

CROS跨域

CORS是一个W3C标准, 全称是跨域资源共享(Cross origin resource sharing)。 它允许浏览器向跨域服务器, 发出异步http请求, 从而克服了ajax受同源策略的限制。

实际上, 浏览器不会拦截不合法的跨域请求, 而是拦截了他们的响应, 因此即使请求不合法, 很多时候, 服务器依然收到了请求.(Chrome和Firefox下https网站不允许发送http异步请求除外)

简而言之, 浏览器不再一味禁止跨域访问, 而是检查目的站点的响应头域, 进而判断是否允许当前站点访问. 通常, 服务器使用以下的这些响应头域用来通知浏览器:主要是前三个返回头

  • Access-Control-Allow-Origin: 指定允许哪些源的网页发送请求.

  • Access-Control-Allow-Methods: 指定允许哪些请求方法.

  • Access-Control-Allow-Headers: 指定允许哪些常规的头域字段, 比如说 Content-Type.

  • Access-Control-Allow-Credentials: 指定是否允许cookie发送.

  • Access-Control-Expose-Headers: 指定允许哪些额外的头域字段, 比如说 X-Custom-Header.

  • Access-Control-Max-Age: 指定preflight OPTIONS请求的有效期, 单位为秒.

关键要看HTTP返回头中Access-Control-Allow-Origin是否有允许跨域的域名。

两种请求

CORS请求分为两种, ① 简单请求; ② 非简单请求。满足如下两个条件便是简单请求, 反之则为非简单请求.(CORS请求部分摘自阮一峰老师博客)

  1. 请求是以下三种之一:

    • HEAD

    • GET

    • POST

  2. http头域不超出以下几种字段:

    • Accept

    • Accept-Language

    • Content-Language

    • Last-Event-ID

    • Content-Type字段限三个值 application/x-www-form-urlencoded、multipart/form-data、text/plain

简单请求

浏览器将发送一次http请求, 同时在Request头域中增加 Origin 字段, 用来标示请求发起的源, 服务器根据这个源采取不同的响应策略. 若服务器认为该请求合法, 那么需要往返回的 HTTP Response 中添加 Access-Control-等字段

一个栗子:假如站点 http://foo.example 的网页应用想要访问 http://bar.other 的资源。以下的 JavaScript 代码应该会在 foo.example 上执行:

//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(); 
  }
}
//让我们看看,在这个场景中,浏览器会发送什么的请求到服务器,而服务器又会返回什么给浏览器:

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 指明的站点。

非简单请求(预请求)

当请求具备以下条件,就会被当成预请求(非简单请求)处理:

  1. 请求以 GET, HEAD 或者 POST 以外的方法发起请求。

  2. 使用 POST,但请求数据为 application/x-www-form-urlencoded, multipart/form-data 或者 text/plain 以外的数据类型。比如说,用 POST 发送数据类型为 application/xml 或者 text/xml 的 XML 数据的请求。

  3. 使用自定义请求头(比如添加诸如 X-PINGOTHER

对于非简单请求,浏览器将发送两次http请求.

  • 第一次为preflight预检(Method: OPTIONS),主要验证来源是否合法. 值得注意的是:OPTION请求响应头同样需要包含 Access-Control-* 字段等.

  • 第二次才是真正的HTTP请求. 所以服务器必须处理OPTIONS应答(通常需要返回20X的状态码, 否则xhr.onerror事件将被触发).

一个例子:使用了自定义请求头的非简单请求

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 发送了一个“预请求”。

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

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天内,浏览器在处理针对该服务器的跨站请求,都可以无需再发送“预请求”,只需根据本次结果进行判断处理。

附带凭证信息的请求

XMLHttpRequest 和访问控制功能,最有趣的特性就是,发送凭证请求(HTTP Cookies和验证信息)的功能。一般而言,对于跨站请求,浏览器是不会发送凭证信息的。但如果将 XMLHttpRequest 的一个特殊标志位withCredentials设置为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: ,则响应会失败。

图像ping跨域

图像ping跨域请求技术是使用 <img>标签。我们知道,一个网页可以从任何网页中加载图像,不 用担心跨域不跨域。

通过监听loaderror事件,知道响应是什么时候接收到的。

let img = new Image();
img.onload = img.onerror = () => {
  alert('接收到响应后处理')
}
img.src = 'http://www.exapmle.com/test?name=aoao';

缺点:

  • 只能发送GET请求;

  • 无法访问服务器相应文本

  • 浏览器和服务器的单向通信

JSONP跨域

JSONP是 JSON with padding的简写,是JSON的一种新方法,由两部分组成

  • 回调函数:当响应到来时应该在页面中调用的函数。

  • 数据:传入回调函数中的JSON数据

基本原理:

  1. 网页通过添加一个<script>标签,利用<script>标签的src属性,向服务器请求JSON数据,这种做法不受同源政策限制;

  2. 服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。因为JSONP是有效的JS代码,所以请求完成后,回调函数会立即执行。

例子如下:

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

优点:

  • 能够直接访问响应文本

  • 支持浏览器于服务器之间的双向通信 缺点:

  • 只支持GET请求

  • 安全性差,易受攻击。请求的其他域不安全的话,响应数据中会被注入恶意代码。

  • JSONP的请求失败与否判断困难。

    • HTML5给<script>标签添加了onerror事件处理程序,但是浏览器支持差

    • 目前只能用计时器定时检测是否接收到了响应

其他方案

《高程》上关于跨域还有其他的解决档方案,不怎么常见,这里列出来,有兴趣的跨域到《高程》上了解。

WebSocket跨域

WebSocket 本质上是一个基于TCP的协议, 它的目标是在一个单独的持久链接上提供全双工(full-duplex), 双向通信, 以基于事件的方式, 赋予浏览器实时通信能力. 既然是双向通信, 就意味着服务器端和客户端可以同时发送并响应请求, 而不再像HTTP的请求和响应. (同源策略对 web sockets 不适用)

原理: 为了建立一个WebSocket连接,客户端浏览器首先要向服务器发起一个HTTP请求, 这个请求和通常的HTTP请求不同, 包含了一些附加头信息, 其中附加头信息”Upgrade: WebSocket”表明这是一个申请协议升级的HTTP请求, 服务器端解析这些附加的头信息然后产生应答信息返回给客户端, 客户端和服务器端的WebSocket连接就建立起来了, 双方就可以通过这个连接通道自由的传递信息, 并且这个连接会持续存在直到客户端或者服务器端的某一方主动的关闭连接.

Comet跨域

在WebSocket出现之前, 很多网站为了实现实时推送技术, 通常采用的方案是轮询(Polling)和Comet技术, Comet又可细分为两种实现方式,

  • 一种是长轮询机制,

  • 一种称为流技术,

这两种方式实际上是对轮询技术的改进, 这些方案带来很明显的缺点, 需要由浏览器对服务器发出HTTP request, 大量消耗服务器带宽和资源. 面对这种状况, HTML5定义了WebSocket协议, 能更好的节省服务器资源和带宽并实现真正意义上的实时推送.

iframe跨域方案

先看几种在不同页面之间使用跨域的可行性技术,后面再看IFrame采用哪一种

可行性方案

document.domain

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

通过修改document的domain属性,我们可以在域和子域或者不同的子域之间通信(即它们必须在同一个一级域名下). 同域策略认为域和子域隶属于不同的域,比如a.comscript.a.com是不同的域,这时,我们无法在a.com下的页面中调用script.a.com中定义的JavaScript方法。但是当我们把它们document.domain属性都修改为a.com,浏览器就会认为它们处于同一个域下,那么我们就可以互相获取对方数据或者操作对方DOM了。

比如, 我们在 www.a.com/a.html 下, 现在想获取 www.script.a.com/b.html, 即主域名相同, 二级域名不同. 那么可以这么做:

document.domain = 'a.com';
var iframe = document.createElement('iframe');
iframe.src = 'http://www.script.a.com/b.html';
iframe.style.display = 'none';
document.body.appendChild(iframe);
iframe.addEventListener('load',function(){
    //TODO 载入完成时做的事情
    //var _document = iframe.contentWindow.document;
     //...
},false);

注:浏览器单独保存端口号。任何的赋值操作,包括document.domain = document.domain都会以null值覆盖掉原来的端口号。因此,赋值时必须带上端口号,确保端口号不会为null.

缺点:有条件限制:主域必须相同

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. 可以实现双向通讯。

缺点:

  1. location.hash会直接暴露在URL里,并且在一些浏览器里会产生历史记录,数据安全性不高也影响用户体验。

  2. 由于URL大小的限制,支持传递的数据量也不大。

  3. 有些浏览器不支持onhashchange事件,需要轮询来获知URL的变化。

不推荐使用

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设置成当前域的一个页面地址。

这个方式非常适合单向的数据请求,而且协议简单、安全. 不会像JSONP那样不做限制地执行外部脚本.

window.postMessage

html5新增的 postMessage() 方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递.

postMessage(message, origin)
  • message: 要传递的数据,html5规范中提到该参数可以是JavaScript的任意基本类型或可复制的对象,然而并不是所有浏览器都做到了这点儿,部分浏览器只能处理字符串参数,所以我们在传递参数的时候建议使用JSON.stringify()方法对对象参数序列化,在低版本IE中引用json2.js可以实现类似效果.

  • origin:字符串参数,指明目标窗口的源,协议+主机+端口号[+URL],URL会被忽略,所以可以不写,这个参数是为了安全考虑,postMessage()方法只会将message传递给指定窗口,当然如果愿意也可以建参数设置为”*”,这样可以传递给任意窗口,如果要指定和当前窗口同源的话设置为”/“。

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

  • event.source:发送消息的窗口。

  • event.origin: 消息发向的网址。

  • event.data:消息内容。

父页面发送消息:

window.frames[0].postMessage('message', origin)

iframe接受消息:

window.addEventListener('message',function(e){
  if(e.source!=window.parent) return;//若消息源不是父页面则退出
    //TODO ...
});

MessageJS

腾讯 AlloyTeam 团队改进的 MessagerJS方案。一些低版本浏览器通用的一种方案。

在一个Iframe中,内部再创建一个Iframe(我们称之为信使,如下图),利用被认为是bug或安全漏洞的特性, navigator对象在父窗口和iframe之间是共享的,基于这一点,

  • 我们可以在父窗口中,在navigator对象上注册一个消息回调函数;

  • 在iframe中,调用navigator上的这个函数并传入参数。 此时可看作,iframe往父窗口的一个函数传递了一个参数,并在父窗口的上下文中执行了,那么就相当于iframe向父窗口发送了一条消息。反之亦然。

原理就是这么简单,好处也是很明显的:

  • 该方案不依赖浏览器的各项设计,不受设置影响,同时完美支持HTTPS

  • 不用创建多余iframe,基于接口调用,不需要轮询,性能大幅提升

  • 良好的接口封装,所有窗口对象统一对待

  • 多iframe也不怕,navigator对象的共享,让iframe之间直接通信成为可能

使用

  1. 在需要通信的文档中(父窗口和iframe们), 都确保引入MessengerJS

  2. 每一个文档(document), 都需要自己的Messenger与其他文档通信. 即每一个window对象都对应着一个, 且仅有一个Messenger对象, 该Messenger对象会负责当前window的所有通信任务. 每个Messenger对象都需要唯一的名字, 这样它们才可以知道跟谁通信.

// 父窗口中 - 初始化Messenger对象
var messenger = new Messenger('Parent'); 
// iframe中 - 初始化Messenger对象
var messenger = new Messenger('iframe1'); 
// 多个iframe, 使用不同的名字
var messenger = new Messenger('iframe2'); 
  1. 在发送消息前, 确保目标文档已经监听了消息事件.

// iframe中 - 监听消息
// 回调函数按照监听的顺序执行
messenger.listen(function(msg){    alert("收到消息: " + msg);});
  1. 父窗口想给iframe发消息, 它怎么知道iframe的存在呢? 添加一个消息对象吧.

// 父窗口中 - 添加消息对象, 明确告诉父窗口iframe的window引用与名字
messenger.addTarget(iframe1.contentWindow, 'iframe1'); 
// 父窗口中 - 可以添加多个消息对象
messenger.addTarget(iframe2.contentWindow, 'iframe2'); 
  1. 一切ready, 发消息吧~ 发送消息有两种方式. (以父窗口向iframe发消息为例)

// 父窗口中 - 向单个iframe发消息
messenger.targets['iframe1'].send(msg1);
messenger.targets['iframe2'].send(msg2); 
// 父窗口中 - 向所有目标iframe广播消息
messenger.send(msg); 

总结

  • document.domain: 主域必须相同,限制太多。

  • location.hash:location.hash会直接暴露在URL里,并且在一些浏览器里会产生历史记录,数据安全性不高也影响用户体验,所以不做考虑。另外由于URL大小的限制,支持传递的数据量也不大

  • window.name:window.name相比来讲就好很多了,支持2M的数据量,并且当iframe的页面跳到其他地址时,其window.name值保持不变,副作用可以说是最小的。

  • postMessage: 现代浏览器的首选方案,如果需要兼容老版本浏览器,那就GG了。

  • MessageJS:引用AlloyTeam团队的JS文件,兼容低版本浏览器。

总的来讲,以前关于Iframe跨域的场景很多,伴随需要兼容各种不同类型的浏览器和低版本问题,产生了各种跨域解决的方案,随着web技术的发展,Iframe使用的越来越少,使用跨域的场景也越来越少,并且目前主要页面都不太需要兼容IE6/7/8等骨灰级浏览器,使用postMessage方案基本满足需求。

开发过程中跨域

在实际开发过程中,有时候经常遇到这样一种情况:

开发过程中会涉及到跨域问题,前端需要向服务请求某个接口,但是由于不同源,浏览器拦截响应,在上线之后项目便在同一个源。

由于工程的HTTP请求基本上都是封装的包,并且使用CORS解决了还需要后端的支持。因此为了快速解决问题,选择绕过了跨域问题,以下有两种绕过跨域的方案

代理跨域

虽然ajax和iframe受同源策略限制, 但服务器端代码请求, 却不受此限制, 我们可以基于此去伪造一个同源请求, 实现跨域的访问. 如下便是实现思路:

  1. 请求同域下的web服务器;

  2. web服务器像代理一样去请求真正的第三方服务器;

  3. 代理拿到数据过后, 直接返回给客户端ajax. 这样, 我们便拿到了跨域数据.

在很前端脚手架设计中,默认添加了这种proxy代理请求的配置,方便我们开发人员快速跳过跨域问题,提高能效。

关闭浏览器安全控制,允许跨域

将浏览器安全检查关闭,允许获取跨域资源。

禁止浏览器跨域安全检查(这里以chrome为例)

"C:\Users\UserName\AppData\Local\Google\Chrome\Application\chrome.exe" --disable-web-security --user-data-dir

参考

Last updated