✨一文看完现有跨域方案🔥

297 阅读16分钟

域的概念

**域(Domain)**作为计算机网络中一个重要的概念,其阐述的是:网络中一组计算机的逻辑集合,是活动目录中的核心单元、是活动目录的分区。其有以下特点:

  1. 域定义了安全边界,每个域均有各自的安全策略以及与其它域的信任关系,在没有授权的情况下,不允许其他域中的用户访问本域中的资源。

  2. 中可以存储上百万个对象,不同的域之间具有层次关系,可以建立域树或域林,以便于域的扩展。域树通常由一个或多个共享连续的名称空间的域组合而成,其中第一个域称作根域,其他域称为子域,如图。

子域.png

  1. 域林通常由一个或多个域树组成,如图10-15所示。其中,各个域树并不共享相同的名称空间,但域林内所有域树的域都具有相同的架构、配置信息、全局目录(Global Catalog)等。

域林.png 所以浏览器根据域的概念,规定了同源策略(Sameoriginpolicy),其是浏览器最重要的安全策略之一。其保证了每个网页的安全性。确保非同源站点无法相互访问资源,JavaScript交互等。具体到浏览器中,同源是指具有相同的:

  • 协议
  • 域名
  • 子域
  • 端口

这其中任何一项不同,皆被视为非同源。会受到同源策略的限制。具体的url的格式:

域名结构.png

注意:上面写的仅仅是url中影响影响跨域的4部分,url还有一下的部分:

  • 虚拟目录部分
  • 文件名部分
  • 锚点部分
  • 参数部分

完整的目录结构如下:

完整域名结构.png

在浏览器中,同源策略限制的对象有:

  1. Cookie、sessionStorage、LocalStorage、IndexedDB 等存储性内容
  2. DOM 节点
  3. AJAX 请求

但是下面的4个请求是允许跨域请求资源的:

  • <img src=XXX>
  • <video src=XXX>
  • <link href=XXX>
  • <script src=XXX>

客户端和服务端通信

jsonp

原理

jsonp(JSON with Padding)本身是一种hack的方法来实现跨域,其利用的原理有两个:

  1. script没有同源策略的限制。
  2. script标签请求的数据会直接执行。这一点就排除了imglink两个标签。

具体原理是:利用scriptsrc属性,发送带有callback回调函数的GET请求,服务端在接受数据后,将要发送的数据作为函数的参数传回client,由于script会立即执行,则可以直接拿到数据。

优缺点

优点

兼容性强,支持IE10以下浏览器跨域问题(CORS不支持)

缺点

  • 仅支持GET方法(由script标签的请求性质决定)

  • 安全性不高,易遭受攻击

实现

client端

原生JavaScript实现
<script>
    function login(res){
        //拿到服务端结果
    }
	//发送JSONP请求
    let jp = document.createElement('script')
	script.type= 'text/javascript'
	jp.src = 'http://www.jsonp.com:8080/login?user = admin & callback = login'
	document.body.appendChild(script)
</script>

可以看到jsonp中的src其实并不是一个脚本地址,而是一个请求地址。

JQuery实现
$ajax({
    url: 'http://www.jsonp.com:8080/login',
    type: get,
    dataType: 'jsonp',
    jsonCallback: 'login',
    data: {
        user: 'admin'
    }
})

function login(res){
    //拿到结果
}
axios实现
$.getJSON("https://www.runoob.com/try/ajax/jsonp.php?jsoncallback=?", function(data) {
	//data    
});

server端

返回数据:

login({'status': true, 'user': 'admin'})

CORS方案

CORS(Cross-origin resource sharing),即跨域资源共享。它由一系列传输的HTTP头组成,这些HTTP头决定浏览器是否阻止前端 JavaScript 代码获取跨域请求的响应。

同源安全策略 默认阻止“跨域”获取资源,即网页发送的跨域请求根本不会达到服务器即被浏览器拦截,服务器没有决定的权限。但是 CORS 给了web服务器这样的权限,即服务器可以选择,允许跨域请求访问到它们的资源。

CORS将不同的请求分为简单请求与复杂请求,根据请求的不同,CORS会进行不同的操作。

对于复杂请求,CORS会先发送一个OPTIONS预请求。具体再后面复杂请求时进行。

首先我们区分一下简单请求与复杂请求:

简单请求

简单请求必须满足几个条件:

  • 使用的方法以下类型:

    • GET
    • HEAD
    • POST
  • 除了被用户代理自动设置的首部字段(例如 ConnectionUser-Agent)和在 Fetch 规范中定义为 禁用首部名称 的其他首部,允许人为设置的字段为 Fetch 规范定义的 对 CORS 安全的首部字段集合。该集合为:

    • Accept
    • Accept-language
    • Content-Type(下面有额外限制)
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
  • Content-Type的值仅限于以下三种:

    • text/plain
    • multipart/form-data-默认表单提交模式,实际上再body部分还是xxx=xxx&yyy=yyy得形式进行传递。
    • application/x-www-form-urlencoded-当表单需要文件上传的时候的类型。
  • 请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。

  • 请求中没有使用 ReadableStream 对象。

例子

simple-req-updated.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/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2.0.61
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[XML Data]

请求中关键的是:

Origin: http://foo.example

它表明请求来源是http://foo.example

响应中关键的是:

Access-Control-Allow-Origin: *

其表明该资源允许被任意外域访问。但一般最好是值开放给规定的域,以保证安全:

Access-Control-Allow-Origin:http://foo.example

复杂请求

所有非简单请求皆为复杂请求,对于复杂请求,浏览器会首先发起一个预检请求(OPTIONS),以获取服务器是否允许该请求。

下面是一次复杂请求的过程:

preflight_correct.png

第一次预请求头部:

OPTIONS /resources/post-here/ 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
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type


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://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

在请求头部中:最重要的几个字段为:

  • Origin: http://foo.example:表示请求的来源为http://foo.example,使服务端进行鉴别是否够允许该请求来源。
  • Access-Control-Request-Method: POST:表示正式请求的方法为POST
  • Access-Control-Request-Headers: X-PINGOTHER, Content-Type:表示正式请求将携带两个自定义请求头部字段:X-PINGOTHERContent-Type,服务器据此决定,该实际请求是否被允许。

在响应头部中:最为重要的几个字段为:

  • 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, Content-Type:表示服务端允许携带自定义请求头部字段为:X-PINGOTHER, Content-Type

  • Access-Control-Max-Age: 86400:表示响应的有效时间为86400秒,也就是24小时。在有效时间内,浏览器无须为同一请求再次发起预检请求。

    **请注意:**浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效。

附带身份凭证的请求

XMLHttpRequestFetchCORS中,可以基于HTTP cookiesHTTP认证信息发送身份凭证。

但在默认情况下,浏览器不会携带身份信息。如果要使浏览器携带信息,需手动设置:

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

注意:不光client要设置withCredentials

server还需要设置一个头部属性:

Access-Control-Allow-Credentials: true

如果响应中缺失 Access-Control-Allow-Credentials: true(第 17 行),则响应内容不会返回给请求的发起者。

注意:

  • 如果要使请求携带身份凭证,服务端不得设置Access-Control-Allow-Origin*
  • 响应首部中也携带了 Set-Cookie 字段,尝试对 Cookie 进行修改。如果操作失败,将会抛出异常。

下面简单列举一下CORS中常用的HTTP头部字段

HTTP头部字段.png

总结来说这两种方法,简单请求由于规范了一些属性,所以只需要验证origin

而复杂请求给了开发者更多可自定义项,则需要服务端进行更多的验证,包括来源,请求方式,请求头字段等。

Fetch

fetch是一个可以用域访问和操作HTTP管道的API。这个方法是为了替代之前的XMLHttpRequest。该API不需要像XMLHttpRequest进行复杂的配置。且不基于回调方案,而是结合了ES6的Promise来进行数据的处理。就像使用axios一样简单。唯一的问题是该方法不能被pollyfill,且兼容性不是特别好,IE所有版本均不兼容。 fetch兼容性.png 但是我们必须了解,因为其是未来的网络连接方案。

fetch全局方法可以接受两个参数,第一个是url,第二个是配置数据(部分如下),并返回一个Promise

  • url: url地址(可以为blob)。

  • options:

    • method: 请求使用的方法,如 GET、POST。
    • headers: 请求的头信息,形式为 Headers值的对象字面量。
    • body: 请求的 body 信息:可能是一个 BlobBufferSourceFormDataURLSearchParams或者 USVString对象。注意 GET 或 HEAD 方法的请求不能包含 body 信息。
    • mode: 请求的模式,如 cors no-cors 或者 same-origin
    • credentials: 请求的 credentials,如 omit、same-origin 或者include。为了在当前域名内自动发送 cookie , 必须提供这个选项。

    其中的mode参数即可以直接设置是否跨域。如果填写no-cors,则该请求会被限制为仅同源可请求。只要这个字段传入一个任意的字符串或者不填,都是默认cors

当然,原理还是CORS,所以服务器还是需要设置字段进行相应的配置。

其实fetch有很多可说,具体可以参考我写的另外一篇文章Fetch方法的使用

中间件代理

描述

中间件代理服务器即利用一个中间服务器转发实际服务器的请求,由于中间件服务器与实际服务器之间不需要遵循同源策略,即可以从实际服务器请求数据。实际上代理的主要作用并不是应对跨域。而是如其名,代理后端真实服务器,比如在分布式设计中,一个代理服务器可以代理后端多台真实服务器,做到具体业务和底层逻辑解耦。

实际上这种方式下,代理服务器仍然需要进行跨域配置,但是免去了后端真实服务器的跨域配置。具体流程如下:

中间件流程.png

例子

在node中,一般使用express做为server容器,其中的express-http-proxy可以直接用来转发请求,作为一个中间件代理

//middleware.js
const app = express();
const http = require("http");
let proxy = require('express-http-proxy');

//配置跨域
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', "*");
  res.header('Access-Control-Allow-Headers', 'Content-Type,Content-Length,Authorization,Accept,X-Requested-With');
  res.header('Access-Control-Allow-Methods','PUT,POST,GET,DELETE,OPTIONS');
  res.header('X-Powered-By', '3.2.1')
  if(req.method === 'OPTIONS') {
    res.send(200);
  } else {
    next();
  }
});


//配置代理服务器,代理/api接口
app.use('/api', proxy('http://realServer.com', {
    //过滤器
    filter: function(req, res){
        return req.method === 'GET'
    },
    //请求路径解析
    proxyReqPathResolver: function(req){
       return req.url+'token=123456'		//请求转发路径
    },
	    //返回数据处理,如果过程有异步操作应返回Promise(可选)
    userResDecorator: function(proxyRes, proxyResData, userReq, userRes) {
        //同步
        data = JSON.parse(proxyResData.toString('utf8'));
        data.newProperty = 'exciting data';
        return JSON.stringify(data);
        //异步
        return new Promise(function(resolve) {
            proxyResData.funkyMessage = 'oi io oo ii';
            setTimeout(function() {
                resolve(proxyResData);
            }, 200);
        });
    },
}))

app.listen(3000)

真实服务器不需要配置跨域,仅一个简单的server即可(这里也采用express

//server.js

const app = express();
const http = require("http");

app.get('/api', function(req, res){
    res.send('REAL SERVER MESSAGE!')
})

业务中,使用代理服务器多用于业务解耦,或者负载均衡,跨域只是其中一个特点。

nginx反向代理

描述

使用nginx反向代理其本质与node中间件代理一样,都是使用一个中间服务器,来转发请求到实际的后端服务器中,但同样,nginx的反向代理多用来做负载均衡或业务解耦,跨域问题的解决只是其中一个特点。

例子

下面那是一个简单的nginx的配置:

// proxy服务器
server {
    listen       80;
    server_name  www.middleware.com;
    location / {
        proxy_pass   http://www.realServer.com:8080;  #关键,反向代理
        proxy_cookie_domain www.realServer.com www.domain1.com; #修改cookie里域名
        index  index.html index.htm;

        # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
        add_header Access-Control-Allow-Origin http://www.middleware.com;  #当前端只跨域不带cookie时,可为*
        add_header Access-Control-Allow-Credentials true;
    }
}

跨页面通信

postMessage

描述

**window.postMessage()**方法可以安全地实现跨源通信。此方法可以不考虑两个页面是否同源。只要能拿到对应窗口的引用对象,就可以使用该api进行通信。

窗口引用

我们可以通过:

  • 子窗口window.open(URL,name,features,replace)

    参数描述
    URL一个可选的字符串,声明了要在新窗口中显示的文档的 URL。如果省略了这个参数,或者它的值是空字符串,那么新窗口就不会显示任何文档。
    name一个可选的字符串,该字符串是一个由逗号分隔的特征列表,其中包括数字、字母和下划线,该字符声明了新窗口的名称。这个名称可以用作标记 和 的属性 target 的值。如果该参数指定了一个已经存在的窗口,那么 open() 方法就不再创建一个新窗口,而只是返回对指定窗口的引用。在这种情况下,features 将被忽略。
    features一个可选的字符串,声明了新窗口要显示的标准浏览器的特征。如果省略该参数,新窗口将具有所有标准特征。在窗口特征这个表格中,我们对该字符串的格式进行了详细的说明。
    replace一个可选的布尔值。规定了装载到窗口的 URL 是在窗口的浏览历史中创建一个新条目,还是替换浏览历史中的当前条目。支持下面的值:true - URL 替换浏览历史中的当前条目。false - URL 在浏览历史中创建新的条目。
    let newwin = window.open('http://www.child.com', 'child')
    
  • iframe

    let newWin = document.getElemetnById('iframe').contentWindow

拿到窗口引用。

语法

otherWindow.postMessage(message, targetOrigin, [transfer]);

  • otherWindow:其他窗口的一个引用,比如iframe的contentWindow属性、执行window.open返回的窗口对象、或者是命名过或数值索引的window.frames
  • message:将要发送到其他 window的数据。它将会被结构化克隆算法序列化。这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化。[1]
  • targetOrigin`:通过窗口的origin属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。这个机制用来控制消息可以发送到哪些窗口;例如,当用postMessage传送密码时,这个参数就显得尤为重要,必须保证它的值与这条包含密码的信息的预期接受者的origin属性完全一致,来防止密码被恶意的第三方截获。如果你明确的知道消息应该发送到哪个窗口,那么请始终提供一个有确切值的targetOrigin,而不是*。不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点。
  • **transfer**可选:是一串和message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

示例

//parent window
let newWin = window.open('http://www.child.com', 'child')
newWin.postMessage('hello', 'https://www.orgin.com')

在子窗口中我们可以添加message事件监听。

window.onmessage = function(e) {
  console.log(e.data) //hello
  //返回消息
  e.source.postMessage('world', e.origin)
}

websocket通信

描述

WebsocketHTML5的一个持久化的协议,它实现了浏览器与服务器的全双工通信,同时也是跨域的一种解决方案

  • websocket本身支持跨域,不会存在CORS的限制。
  • WebSocket和HTTP都是应用层协议,都基于 TCP 协议。
  • WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据。
  • WebSocket 在建立连接时需要借助 HTTP 协议,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关了。

例子

在前端我们使用Websocket构造器来新建一个Websocket实例。

let socket = new Websocket('ws://websocketServer.com')
socket.onopen = function(){
    //向服务器发送数据
    socket.send('client-connect...')
}

socket.onmessage = function(e){
    //接受服务器返回的数据
    console.log(e.data)	
}

在后端我们采用node.js,其他语言差别不大。

let express = require('express')
let app = express()
let Websocket = require('ws')
let wss = new WebSocket.Server({port:3000});
wss.on('connection', function(ws){
    ws.on('message', function(data){
        console.log(data)				//client-connect...
        ws.send('server-connect...')
    })
})

document.domain + iframe

该方法仅适用于主域相同,子域不同的场景。

原理

两个页面都通过js强制设置document.domain为基础主域,就实现了同域。 比如 a.test.com 和 b.test.com 适用于该方式。只需要给页面添加 document.domain ='test.com' 表示二级域名都相同就可以实现跨域。

例子

<!-- a.html -->
<body>
    <iframe src="http://source.com:3000/b.html" frameborder="0" onload="load()" id="frame"></iframe>
    
    <script>
        document.domain = 'source.com'
    	function onload(){
            //读取b.html中的变量
            console.log(frame.contentWindow.a);
        }
    </script>
</body>
<!-- b.html -->
<body>
   hellob
   <script>
     document.domain = 'source.com'
     let a = 100;
   </script>
</body>

两个hack方法

下面的两个方法是利用一些hack的方法进行通信,往往更加麻烦,所以参考一下即可。

window.name + iframe

原理

window.name属性的独特之处:name值在不同的页面(甚至不同域名)加载后依旧存在(这一点很重要),并且可以支持非常长的 name 值(2MB)。

例子

其中a.htmlb.html是同域的,都是http://localhost:3000,但b.htmla.htmliframe,则a.htmlb.html可以使用window.name进行通信;而c.html是http://localhost:4000

<!-- a.html(http://localhost:3000/b.html) -->
  <iframe src="http://localhost:4000/c.html" frameborder="0" onload="load()" id="iframe"></iframe>
  <script>
    let first = true
    // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
    function load() {
      if(first){
      // 第1次onload(跨域页)成功后,切换到同域代理页面
        let iframe = document.getElementById('iframe');
        iframe.src = 'http://localhost:3000/b.html';
        first = false;
      }else{
      // 第2次onload(同域b.html页)成功后,读取同域window.name中数据
        console.log(iframe.contentWindow.name);
      }
    }
  </script>

b.html为中间代理页,与a.html同域,内容为空。

<!-- c.html(http://localhost:4000/c.html) -->
  <script>
    window.name = 'hello'  
  </script>

本方法利用了window.name属性在切换源后不变的性质,将数据传送到外域。但是比较复杂,需要用第三个页面进行中转,所以一般不用。

location.hash + iframe

原理

window.name上一种方法相似, a欲与b跨域相互通信,通过中间页c来实现。 三个页面,不同域之间利用iframelocation.hash传值,相同域之间直接JavaScript访问来通信。

例子

其中a.htmlb.html是同域的,都是http://localhost:3000,但b.htmla.htmliframe;而c.htmlhttp://localhost:4000

<!-- http://www.localhost:3000/a.html -->
<iframe id="iframe" src="http://www.localhost:4000/b.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');

    // 向b.html传hash值
    setTimeout(function() {
        iframe.src = iframe.src + '#user=admin';
    }, 1000);
    
    // 开放给同域c.html的回调方法
    function onCallback(res) {
        alert('data from c.html ---> ' + res);
    }
</script>
<!-- http://www.localhost:4000/b.html -->
<iframe id="iframe" src="http://www.localhost:3000/c.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');

    // 监听a.html传来的hash值,再传给c.html
    window.onhashchange = function () {
        iframe.src = iframe.src + location.hash;
    };
</script>
<!-- http://www.localhost:3000/c.html -->
<script>
    // 监听b.html传来的hash值
    window.onhashchange = function () {
        // 再通过操作同域a.html的js回调,将结果传回
        window.parent.parent.onCallback('hello: ' + location.hash.replace('#user=', ''));
    };
</script>

总结

上面总结了很多方法,

  • 但是CORSjsonp是使用最多的跨域方法,CORS在现代开发中使用最多,但是jsonp兼容性更强,对于低版本的浏览器(比如IE10以下),可以实现跨域。
  • 代理方法的用处不仅限于解决跨域,它有更大的作用,后期再探讨。
  • 后面页面通信中,postMessage使用的更多,其他的hack方法仅在特定的环境下才有较好的效果。

参考