深入浅出前端跨域

183 阅读13分钟

来自Chrome的一个截图

聊到跨域,我想你很有可能是奔着这个错误而来的,抑或是碰到了关于跨域的面试问题。

chrome 跨域

首先来翻译一下上面的报错信息:

加载https://.stg.api....地址失败:预备请求的响应没有通过访问检查:(因为)请求的资源中没有"Access-Control-Allow-Origin"头部,因此来自"example.com:8081"的源不被允许访问。

啥意思?翻译成:人话!

你的请求头部缺少了一个"Access-Control-Allow-Origin",所以访问被无情地拒绝了!

那我是不是在header中加一个这样的头部就OK啦?

如果这样就行,那么还来这干嘛。说到这,你就该对跨域有一个深入的了解了!

跨域问题的由来

首先你得知道Cookie是做什么的

Cookie除了可以在本地存储用户数据,它默认还会随HTTP请求一起发送给服务器。

同源限制

小白在银行有个账户,有一天这个小朋友想在浏览器中登录银行官网给他的小伙伴打几毛钱,登录之后,网页没关。然后,他不知道哪根筋不对,又登录了一个赌博诈骗网站,人家诱导他点了一个链接,后来发现,钱没了~人还在!(不过,一会可能也没了……)

哈哈,当然,现在的银行网站不可能做的那么low了,但是这在很久很久以前的网站中是确实出现过这样的问题的,这个问题的根源其中之一就在于Cookie。

分析下流程:

  1. 小白登录的银行,输入用户名密码之后,银行服务器通过Set-Cookie字段在浏览器设置了一个用户身份标志的Cookie(比如内容是userId=uid123456)。
  2. 当需要转账时,可能要请求一个链接,比如::bank.com/transfer?mo…
  3. 银行通过cookie判断userId,然后和数据库作对比,查找出是来自小白客户的请求,然后把1000块钱打给了小红。

等等,中间是不是错过了什么!??

哈哈,是的,那个链接不是小白在银行页面里点的,而是他在登陆了银行页面之后,在另一个赌博诈骗的网站中点了一个伪造的链接,而这个链接的实际地址就是那个转账地址!!

这就是没有同源限制导致的!如果银行服务器可以取得请求的真实来源不是自己网站,抑或是浏览器截获了这个伪造请求,那么诈骗不就失败了吗?

浏览器的同源策略

后来为了解决这个问题,浏览器加入了同源策略,从客户端层面屏蔽了非本网站的请求。 那么什么是同源策略?

如果两个页面的协议,端口(如果有指定)和域名都相同,则两个页面具有相同的源,我们称之为同源。

不过,奇葩的IE8的此时再次展现了它的另类,它这样理解同源:

  1. 两个高度互信的域名,不会被同源策略限制。(比如baidu.com和www.baidu.com被IE8理解成同源,但在现代浏览器不是)
  2. 端口号不在同源策略中。(比如baidu.com:8080和baidu.com:8090)

同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。

而上面的银行转账的例子就是典型的CSRF攻击(跨站资源伪造)。

哇塞~又学到了一个新名词!

避免CSRF攻击的方式

  1. 检查HTTP请求Header中的Referer字段,它标明请求的来源,只有在同一网站请求Referer字段才会和请求网站域名一样;
  2. 添加Token校验,不把用户关键数据存在cookie中,而是服务端生成一个伪随机数附加给客户端,当发送请求时随同伪随机数一并发送给服务器进行校验;

前者可以伪造,而后者才是更安全的方式。

同源限制可以在浏览器端通过代码绕过吗?

目前,浏览器允许通过document.domain把当前域改成同一父域的子域或上层父域,如:

// 当前iframe源是:https://app.baidu.com/main.js
document.domain = 'baidu.com'
// 源就被改为了:https://baidu.com/main.js
// 这样可以绕过同源检查,但是不能把app.baidu.com 改成 google.com 这种跨父域同源

不过这种做法支持性局限性很强。

跨源脚本API访问

  1. 当两个文档不同源时,例如iframe嵌套会导致文档的window.location跨域只能访问不能修改。

  2. XMLHttpRequest请求如果没有设置特殊头部会受到同源限制;

不过,以下标签允许嵌入非同源的资源:

  1. script:但是语法错误只能在同源脚本中捕捉到;
  2. link嵌入css,请求时需要设置正确的Content-type;
  3. img/video/audio等媒体资源;
  4. object/embed/applet等插件;
  5. frame/iframe等嵌入其他网站页面,但是如果站点使用X-Frame-Options头部可以阻止跨源;
  6. @font-face引入字体;

例如:

<img src="非同源的资源"/>

那如何解决这种同源的限制呢?

跨域问题解决方案

解决同源限制就是解决跨域问题,解决跨域的问题分为常规和hack两种方式,hack也就是利用js和html一些特性变相绕过限制。 那么,先说下hack方式:

1. JSONP

想必这个名词你不止听过一次了吧?但就是不知道它的原理! 其实JSONP很简单,它就是利用script可以请求其他域资源的特性来实现数据请求的,但JSONP只能解决简单的GET请求跨域哦。

JSONP跨域原理

  1. HTML中通过js动态引入一个script标签,script的src属性是请求的跨域资源链接,但地址格式一般写成http://.....?callback=方法名的形式,这样做的目的是为了配合服务器端代码。
  2. 后端服务根据前端请求的地址,拿到callback这个参数,然后返回给客户端的数据就是一段立即执行的js,即:callback方法名(数据)。

下面贴出了代码:

前端js

function jsonpCallback(data) {
    console.log(data)
}

function jsonpCors() {
    var script = document.createElement('script')
    script.src = "http://127.0.0.1:8080/jsonp-cors?callback=jsonpCallback"
    document.body.insertBefore(script, document.body.firstChild)
}

服务器端js

const query = qs.parse(req.query)
    if (query.callback) {
      // jsonpCallback({})
        res.send(`${query.callback}(${JSON.stringify({ jsonpData: "这就是回调给你的JSONP数据" })})`)
    } else {
        res.send(`console.log('没有发现callback参数')`)
    }

因为script脚本的限制,JSONP只能进行GET请求,而且不能传递复杂数据。

2. iframe跨域

JSONP可以实现GET请求的跨域,但是无法实现POST请求方式,因为script标签请求就是用的GET方法。那么POST请求如何通过hack方式来跨域呢?这时,iframe就派上用场啦~

iframe跨域原理

因为一个网页可以通过iframe嵌入其他源的页面,这样就可以通过在网页中创建一个iframe标签,然后在iframe中模拟一个表单上传操作来规避当前域名下不能上传信息的问题。

利用iframe来实现post请求

// 首先创建一个用来发送数据的iframe.
const iframe = document.createElement('iframe')
iframe.name = 'iframePost'
// 注册iframe的load事件处理程序,如果你需要在响应返回时执行一些操作的话.
iframe.addEventListener('load', function () {
    console.log("上传完成")
})
document.body.appendChild(iframe)
const form = document.createElement('form')
form.action = 'http://127.0.0.1:8080/forbidden-cors'
form.enctype = "multipart/form-data"
// 在指定的iframe中执行form
form.target = iframe.name
form.method = 'post'
const node = document.createElement('input')
node.name = 'info'
node.value = '我要拿到跨域信息'
form.appendChild(node)
// 表单元素需要添加到主文档中.
document.body.appendChild(form)
form.submit()
// 表单提交后,就可以删除这个表单,不影响下次的数据发送.
document.body.removeChild(form)

iframe跨域仅能解决表单的上传或模拟POST操作,而且上传的数据是表单格式而不是json格式,但是PUT、PATCH、DELETE方法不支持。

上面两个就是最常用的非常规方法来解决跨域,但接下来才是我们学习的重点哦!

3. 跨域资源共享-CORS

XMLHTTPRequest处理跨域

大多数现代浏览器都提供了XMLHttpRequest类用来提供网络请求,你用的jQuery.ajax其实内部封装的就是这个类。

XMLHttpRequest在某些情况下的请求可以处理成简单的跨域(IE108不支持):

以下情况符合简单跨域的情况:

  1. 只进行GET、POST、HEAD请求。
  2. 请求的Header中的Content-Type设置成了如下三种类型:(不包括application/json哦)
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded

在上面简单跨域的情况下,客户端发送请求只需要在请求的Header中加入如下一条信息即可:

Origin:跨域的请求源

Orgin:http://127.0.0.1:5500

服务器返回的Access-Control-Allow-Origin如果包含上面的Origin则跨域成功

Access-Control-Allow-Origin:http://127.0.0.1:5500

这个就是刚才文章开头的解决方案,只需要保证服务器响应的Header中包含Access-Control-Allow-Origin为请求发送的Origin即可。

XMLHttpRequest进行简单跨域请求

console.log('请求一个简单的跨域')
// 注意只能通过GET方法并且需要保证Content-Type属于那三种简单跨域类型才能简单跨域哦
var xhr = new XMLHttpRequest()
xhr.open("GET", "http://127.0.0.1:8080/simple-cors", true)//注意这里
xhr.setRequestHeader("Content-Type", "text/plain")//注意这里
xhr.onreadystatechange = function () {
  if (xhr.readyState === 4) {
    if (xhr.status >= 200 || xhr.status < 300 | xhr.status === 304) {
      console.log(xhr.responseText)
    } else {
      console.error('请求失败,错误信息:' + xhr.statusText)
    }
  }
}
xhr.send(null)

但是XHR简单跨域有以下限制:

  1. 默认情况下不能通过xhr.setRequestHeader()设置自定义头部;
  2. 默认只支持GET、POST和HEAD。
  3. 不能发送和接收cookie。
  4. 调用xhr.getAllResponseHeaders()返回空字符串;

由于简单跨域有很多限制,因此后来提出了另一种解决上述问题的机制:**Preflighted Requests(请求预检)**机制。因为这个是一个比较新的解决方案,所以IE8和IE9是不支持这个机制的。

预检机制的原理

预检机制的原理就是利用OPTIONS方法,在发送真实请求前,先自动先发送一个OPTIONS请求询问服务器能否继续接下来的请求,OPTIONS请求会发送以下头部:

Origin:请求源地址;
Access-Control-Request-Method:即将请求的方法;
Access-Control-Request-Headers:即将请求的自定义的Header,多个Header以逗号分隔;

Orgin:http://127.0.0.1:5500
Access-Control-Request-Method:GET
Access-Control-Request-Headers:content-type

接下来服务器会对OPTIONS请求返回一个200状态码,如果允许跨域的话,响应的Header中携带以下信息:

Access-Control-Allow-Orgin:允许客户端请求的域

Access-Control-Allow-Methods:允许请求的方法

Access-Control-Allow-Headers:允许放置的自定义头部

Access-Control-Allow-Max-Age:这个Preflight请求缓存的时长(单位:秒)

Access-Control-Allow-Orgin:http://127.0.0.1:5500
Access-Control-Allow-Methods:GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers:content-type
Access-Control-Allow-Max-Age:172800

Preflight请求之后,结果会被缓存(缓存时间不超过Access-Control-Allow-Max-Age指定的时间)。

接下来,浏览器就会根据OPTIONS响应返回的Header来判断是否可以继续自动发送跨域请求了。

注意,即便上面的跨域预检请求之后成功请求了跨域,但是跨域默认是不带cookie的。

默认情况下,跨域请求不能携带cookie、HTTP认证等凭据,但是通过设置XHR对象的withCredentials=true可以让Request请求支持携带凭证,如果服务器接受带凭据请求,会在响应的Header中加入:

Access-Control-Allow-Credentials:true

但如果服务器不接受凭证,此时XHR就会执行onerror(请求失败)。这就是我们使用axios这些第三方库包含withCredentials选项的原因。

var xhr = new XMLHttpRequest()
if("withCredentials" in xhr){
  // IE10如果使用XMLHttpRequest,是没有withCredentials属性的,因此IE10的XMLHTTPRequest不支持跨域。
  xhr.open(method,url,ture)
}

哦,原来如此!

4. IE8中的跨域问题解决方式

IE8不仅不支持上面的复杂的跨域方式,IE8中还引入了一个比较奇葩的XDomainRequest(XDR)类型的请求类。它与XMLHttpRequest类似,但是限制非常严格,XDmoainRequest(XDR)有以下限制:

  1. cookie不会在客户端和服务器之间传输,也就是没有cookie传输;
  2. Request请求的Header只能设置Content-Type;
  3. Response的Header无法访问;
  4. 只支持GET和POST。

以下代码只能在IE8环境下运行

var xdr = new XDomainRequest();
xdr.onload = function(){
  console.log(xdr.responseText)
}
xdr.onerror = function(){
  console.log("发生了错误")
}
xdr.open("GET","服务器地址")
xdr.send(null)

其实,使用上和XMLHttpRequest还是没啥大区别的对吧,哈哈哈!

5. 后端做代理转发

前面的跨域方式其实或多或少都需要后端来参与,那与其这样,倒不如直接让后端的小伙伴来搞咯~

好,那么到最后我们就说说实际工作中我们最常用到的跨域方式了,也就是服务端为我们做跨域。相当于后端的开发小伙伴帮我们完全搞定了这部分工作,所以后端的小伙伴其实比前端更辛苦的~

由于跨域是浏览器层面做的事情,相当于我们的不同域请求直接被浏览器给拦下了,所以这个限制对于后端的HTTP请求来讲是不存在的哦。 而且对于我们前端来讲,后端做了跨域的事情,对于前端就是透明的,给用户的感觉也是一直在一个域名下请求。

ngix反向代理(生产环境)

后端的代理转发最常用的是Ngix进行反向代理,因为这是个轻量服务,所以在做高并发请求方面的性能非常卓越。 具体步骤就是,在服务器上部署一下ngix,然后修改下配置文件即可。然后把我们的前端代码部署在ngix服务下面就OK咯~

ngix反向代理跨域配置

// proxy服务器
server {
    listen       80;
    server_name  当前域名;
    location / {
        proxy_pass   实际转发到的跨域目的域名;  #反向代理
        index  index.html index.htm;
        add_header Access-Control-Allow-Origin 当前域名; 
        add_header Access-Control-Allow-Credentials true;
    }
}

webpack-dev-server配置(开发环境)

如果是我们前端小伙伴自己开发本地测试,因为大家使用的都是webpack进行代码打包,因此在测试时使用webpack-dev-server也能做到后端跨域(底层使用的是NodeJs)。

具体方案就是在配置webpack时候,在配置项中加入如下配置即可:

webpack.dev.config.js文件中加入

module.exports = {
  //其他webpack配置
  devServer: {
    proxy: {
      '/api': 'http://localhost:3000',
      // 或者
      '/api': {
          target: 'http://www.example.org', // 转发目标的地址
          changeOrigin: true, // 虚拟主机站点需要设置成true,默认false即可
          ws: true, //代理websockets
          pathRewrite: {
            '^/api/old-path': '/api/new-path', // 如果请求地址和代理地址不一样,可以在这里修改
            '^/api/remove/path': '/path' 
          }
      }
    }
  }
};

webpack的文件配置,需要查阅官方文件,这里一两句话说不清,webpack-dev-server的配置规则,可以参考这里

好了,常见的跨域就这么几种,最后忘了提一下window.postMessage也可以实现不同域页面之间的信息传递,但是因为这不属于请求的范畴,所以就不深入展开了。

最后,祝你好运,成长为前端大神!

啊啊啊~即刻收藏!