阅读 4223

跨域多方位解决方案

本次分享由趣头条cpc商业化技术部(周志祥-混元霹雳手)进行分享!欢迎大家投递简历加入趣头条,邮箱地址为qianjiongli@qutoutiao.net 期待您的加入。

你了解跨域吗?

了解(continue) 不了解 (end)

为何会产生跨域?

跨域问题来源于浏览器同源策略的限制问题导致的。

浏览器为何要设置同源策略?

正是因为浏览器要出于安全考虑。如果缺少了同源策略,浏览器很容易受到XSSCSRF等攻击。(XSSCSRF可以单独成为一个额外的知识点) 此时会导致一个域名下网页的操作就可以直接拿到另一个非同域名下网页的任何信息,或者一个网页可以随意请求到不同域名服务器下的接口数据。

什么是同源策略?

同源策略是一种约定,这是浏览器核心的安全功能点之一。所谓的同源策略指的是【协议 + 域名 + 端口】三者相同,如果两个相同的域名指向同一个ip地址,也是非同源的情况。同时地址印射对应的ip两者也是非同源情况。

同源策略会存在那些限制?

DOM节点

对于DOM节点只能操作当前域名下网页打开的DOM节点内容。

存储信息

对于cookiesessionStoragelocalStorageindexedDB等存储信息也不能非同源获取

ajax请求

对于ajax网络请求时,请求处于非同域的情况下会被浏览器自动拦截报错。

举几个造成跨域的场景的例子?

前面说过当协议、域名、端口号中任意一个不相同时,都是跨域。同样包括(一级域名与二级域名的不同) 互相请求资源的情况下是一种跨域状态。

跨域的地址场景图

通过什么方式可以解决跨域?

可以通过JSONP的原理

首先明白对于浏览器加载资源时可以通过:

  1. img
  2. script
  3. link

以上几个标签是允许跨域加载资源的。意思就是在www.baidu.com域名下静态html文件中的script标签可以加载wwww.google.com服务器下的脚本资源等。

通过以上标签可以加载跨域资源的理解,那我们可以通过包装手段从其它域获取到期望的数据。

讲讲JSONP的实现原理?

之前已经有了原理的思路的铺垫。那就利用script标签这一允许跨域加资源的特性包装数据进行讲解。

实现流程

// index.html
// jsonp的实现模拟
function jsonp({ url, params, callback }) {
  return new Promise((resolve, reject) => {
    let script = document.createElement('script')
    window[callback] = function(data) {
      resolve(data)
      document.body.removeChild(script)
    }
    params = { ...params, callback }
    let arrs = []
    for (let key in params) {
      arrs.push(`${key}=${params[key]}`)
    }
    script.src = `${url}?${arrs.join('&')}`
    document.body.appendChild(script)
  })
}

// 调用方式
jsonp({
  url: 'http://localhost:3000/getUser',
  params: { name: 'peter' },
  callback: 'user'
}).then(data => {
  console.log(data)
})
复制代码

通过以上代码实现了一个基本的JSONP的调用执行代码。

  1. 声明一个JSONP的模拟函数, 传入的参数分别为请求地址请求参数前后端约定的包装函数名、 内部通过返回promise机制来优雅的解决数据返回的获取方式。

  2. 通过script不存在跨域请求资源的机制创建一个script临时标签。把向后台请求的地址和参数组合成query参数的形式。 请求地址: http://localhost:3000/getUser?name=peter&callback=user

关健点是把包装的函数名(key作为callback, value作为user) 包装函数名是前后端一个约定。

  1. 最后组装后的script标签插入到document文档中,此时浏览器就会自动向标地址发起请求。

后台返回的结果原理

// app.js 用express脚手架模拟的配合前台callback封装的返回结果

app.get('/getUser', function(req, res, next) {
  let { name, callback } = req.query
  console.log(name) // peter
  console.log(callback) // user
  res.send(`${callback}({
    code: 0,
    msg: '请求成功',
    data: {
      id: 1234
    }
  })`)
});
复制代码

后台会通过query参数进行解析。如果此时返回的结果是一个对象,对象中存在msg消息,请求状态码code,数据信息data

可能你会疑问为什么返回的结果的值是放在一个user执行函数中。这就是JSONP的核心原理。回头再看看这段没有解释的代码段:

window[callback] = function(data) {
  resolve(data)
  document.body.removeChild(script)
}
复制代码

当执行自己封装的jsonp的方法的时候在全局定义一个函数。此函数名则是前端与后端约定的函数封装名。当后台返回结果时会执行约定好的全局函数。就是执行上方代码段, 数据参数会通过resolve执行返回。最后删除对应的请求script标签。

JSONP和AJAX对比,区别点在那里?

相同点:

JSONPajax两者相同点都是客户端向服务端发起请求。

不同点:

JSONP属于利用script标签进行了非同源策略请求,而ajax是同源策略请求。

JSONP优缺点

优点:

JSONP的优点是兼容性很好。因为利用的是script标签可以非同源请求机制。这是每个浏览器基础特性。

缺点:

只支持query参数的这种get请求方式,交互方式存在局限性。也容易受到xss的攻击。

如果后台不支持JSONP的封装方式怎么办?

可以通过CORS网络通信技术。(全称Cross-Orgin Resource Sharing),对于CORS同样也需要前后端进行一个配合。但是关健点在于后台的配置。可能你会认为。即然是后台进行配置,为什么前台也需要充分的了解。因为无论在生产还是开发的模式下, 跨域首先对前端的影响面是最大的, 只有充分的了解才能向后台去表达后台才能准确的设置和进行配合。

简单的跨域请求需要建议后台进行什么设置?

前台模拟设置

先本地创建一个index.html写入请求脚本。通过http-server -p 4000启动在本地4000端口下。

// index.html
let url = 'http://localhost:3000/getUser';
let xhr = new XMLHttpRequest();
xhr.open('get', url, true);
xhr.send();
复制代码

后台模拟设置

通过express框架设置请求地址,服务启动在本地3000端口下。

// app.js
let express = require('express')
let app = express()

app.get('/getUser', function(req, res) {
  res.send({
    code: 0,
    msg: '请求成功',
    data: {
      id: 1234
    }
  })
})

app.listen(3000)
复制代码

浏览器返回结果

访问http://127.0.0.1:4000/index.html可以通过Network控制台可以看到浏览器端向后台http://localhost:3000/getUser服务接口地址发出请求。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段,就知道出错了,从而抛出一个错误,被XMLHttpRequestonerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。虽然返回的 Status Code 状态码是 200 OK,但是response响应头里并没有返回期望的值。同样在console控制台可以发现:

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
复制代码

CORS策略阻止了从http://127.0.0.1:4000访问http://localhost:3000/getuser处的XMLHttpRequest:请求的资源上没有'Access- control - allow-origin'头。

这就是一个最简单的CORS的安全策略,从报错可以很明显的明白你需要告诉后台需要设置'Access- control-allow-origin'头。

后台解决方案

// app.js中添加

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*')
  // res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
  next()
})

复制代码

在接收到请求时做一层中间件的过滤, 以下两者方式皆可。

  1. 返回时设置响应头的Access-Control-Allow-Origin*(代表所有域名向当前服务请求都允许跨域访问)
  2. 返回时设置响应头的Access-Control-Allow-Origin为指定的域名。其它域名都不允许进行一个跨域访问

设置Access-Control-Allow-Origin头就可以解决了所有的跨域问题了麻?

Access-Control-Allow-Origin头的设置仅仅只能解决简单的跨域请求

简单的跨域请求条件:

条件1: 只能允许以下的请求方法

  • GET
  • HEAD
  • POST

条件2: Content-Type允许条件

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

条件3: 不能超过http的头信息以下字段

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID

那其它请求方式如何解决?属于什么类型的跨域请求?

其它的请求方式被称之为复杂的跨域请求。一旦不符合简单跨域请求策略的时候那就是复杂的跨域请求:

复杂的跨域请求解释:

  1. 除了简单的跨域请求的方法。比如PUTDELETE
  2. 除了简单的跨域请求的Content-type类型。比如application/json
  3. 自定义的header
  4. 不同域名下的cookie传输

尝试解决复杂跨域的几种情况

1.put、delete等请求方法造成复杂请求

// 修改请求方法
- xhr.open('get', url, true);
+ xhr.open('put', url, true);
复制代码
// 修改后台接收请求方法
app.put('/getUser') // 省略... 对于后台只是把get请求换成put接收请求
复制代码

在浏览器的netWork中发现并没有发送put请求,在General中的Request Method发现发送了一个OPIONS的预检请求(关于预检后续会在解决跨域问题中通过关闭浏览器策略中专门介绍相关详细知识点)

同时浏览器中会被发出报错信息:

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
复制代码

解决方案:

// 在app.use中添加新的设置头
// res.setHeader('Access-Control-Allow-Methods', '*')
res.setHeader('Access-Control-Allow-Methods', 'PUT')
复制代码

以上设置了接收允许那些请求方法:

  • 设置*, 表示所有请求方法都允许。
  • 设置对应的请求方法以逗号分隔。

2.content-type造成复杂请求

+ xhr.setRequestHeader('content-type', 'application/json');
复制代码

在之前谈论简单跨域请求条件二, 关于content-type类型对于简单的跨域请求只支持三种。设置其它的则会产生复杂的跨域请求。当设置content-type: application/json的情况下,同样的浏览器会发出报错信息:

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.
复制代码

从报错提示可以看出后台需要对复杂跨域请求content-type进行一个额外的设置:

// 在app.use中添加新的设置头
+ res.setHeader('Access-Control-Allow-Headers', 'content-type')
复制代码

3.自定义头造成复杂请求

+ xhr.setRequestHeader('X-Customer-Header', 'value');
复制代码

在之前谈论简单跨域请求条件三中, 除了以上几种http请求头之后,都属于自定义头。在请求带入时会造成复杂的跨域请求, 同样的浏览器会发出报错信息。

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: Request header field x-customer-header is not allowed by Access-Control-Allow-Headers in preflight response.
复制代码

同样的原理对于前台设置的自定义头后,后台在接收的时候同样也要进行允许设置接收前台自定义传输出来的自定义头。

res.setHeader('Access-Control-Allow-Headers', 'content-type, X-Customer-Header')
// res.setHeader('Access-Control-Allow-Headers', '*')
复制代码

Access-Control-Allow-Headers设置的时候,可以用逗号分隔,进行多个自定义头的设定。同时也可以传入*,允许所任何自定义头。

谈谈CROS中的cookie?

绝对同域的情况下

在绝对同域的情况下。前台向后台请求的接口或者请求文件的时候,会自动把cookie带入请求头中。

在非同域的情况下

在非同域的情况下。需要使用CORS的策略进行传输。默认情况下,cookie并不会带入请求头中,需要对xhr设置请求凭证。

xhr.withCredentials = true
复制代码

简单的跨域请求与cookie

如果此时是简单的跨域请求, 设置withCredentials = true的情况下。请求头中会带入cookie信息, 后台接收请求并且会发送到前台, 此时浏览器端从response中可以看到数据已经返回,但是并不能获取的后台返回的数据, 因为此时会被xhr的错误进行捕获,浏览器控制台会出现以下提示:

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://localhost:4000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.
复制代码

复杂的跨域请求

如果此时是复杂的跨域请求,设置withCredentials = true的情况下。此时会发送一个OPTIONS请求。浏览器发出的错误信息仍然是与简单的跨域请求报错一致。

解决方案

此时前台发送cookie凭证, 同样的后台一样需要同意接收凭证。

res.setHeader('Access-Control-Allow-Credentials', true)
复制代码

反向原理:

如果后台同意接收凭证。而前台没有设置发送凭证的情况下。就算后台发送到前台的响应头中设置了cookie信息(set-cookie头),无论是简单的跨域请求还是复杂的跨域请求都会导致cookie塞入无效,可以查看appliation/cookie中, 不会有后台写入的cookie信息。

保持同源策略

为了安全问题。cookie本质上还是保持了同源策略的模式。在前后台都设置了发送/接收凭证之后, 对于反回的origin头的设置res.setHeader('Access-Control-Allow-Origin', '*') 不能为*, 需要设置成指定请求的来源 res.setHeader('Access-Control-Allow-Origin', req.headers.origin)

合法组合与非法组合。

当设置Credentials的时候,后台需要知道Access-Control-Allow的合法与非法组合性。 一旦Access-Control-Allow-Credentials设置为true的时候, 此时以下几个不能设置为*, 需要进行指定, 否则以下三者一率视为无效设置。

  • Access-Control-Allow-Headers
  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods

CORS情况下如何在xhr中拿到响应头中的信息?

可以通过xhr.getResponseHeader方法进行获取。但是此方法只能拿到6个基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma

在后台响应的时候可以响应头中塞入一些自定义的头和值。

res.setHeader('name', 'peter')
复制代码

在响应体的报文中可以看到:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: content-type, X-Customer-Header
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Origin: http://localhost:4000
Connection: keep-alive
Content-Length: 50
Content-Type: application/json; charset=utf-8
Date: Sun, 17 Feb 2019 08:18:08 GMT
ETag: W/"32-oUKytSTXnBL0hnySFj9PpHgmBQk"
name: peter   // 重点在这里
X-Powered-By: Express
复制代码

通过报文可以发现返回的很多之前后台设置的信息和这里最关健的name头信息。但是通过以下方法测试之后结论:

xhr.onreadystatechange = function() {
  if (xhr.readyState === 4) {
    if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
      console.log(xhr.getResponseHeader('Content-Type'))
      console.log(xhr.getResponseHeader('name'))
    }
  }
}
复制代码

xhr返回成功之后。分别获取两个头信息。

  • Content-Type 则会返回 application/json; charset=utf-8

  • name 则会提示报错信息,并且返回null空值。

Refused to get unsafe header "name" // 拒绝获取不安全的头信息“name”
复制代码

可以明确的知识,除了之前提到的以上六种头信息可以进行获取之外,其余的一律都需要在后台进行允许那响应些头访问的设置。

res.setHeader('Access-Control-Expose-Headers', 'name')
复制代码

此时浏览器中报错信息不会存在,同时也能打印出name在响应头中的值。注意 如果设置的值为 * 则无效。需要对指定字段头进行设置。

复杂的跨域请求会造成每次请求都发送一个OPTIONS请求,如何解决?

通过以上的所有对复杂的跨域请求的分析清楚的认识到,那些请求方式会造成发送预检,一句话概括,**Access-Control-Max-Age 这个响应首部表示 preflight request (预检请求)的返回结果(即 Access-Control-Allow-Methods 和Access-Control-Allow-Headers 提供的信息) 可以被缓存多久。**这样对network中的请求观察和请求性能来说都不友好。如果做到友好又安全的机制。

对预检进行一个时间请求有效期

res.setHeader('Access-Control-Max-Age', 600)
复制代码

对预检请求设置10分钟的过期时间(时间可以根据项目情况进行自定义)。但是对于每个浏览器的缓存时间机制都不一样。在本地调试的时候,有时候你会发现设置了预检的过期时间并不生效。注意一下可能开启了浏览器的Disable cache导致了此原因

在前后端联调时,不通过后端设置,如何解决跨域问题?

关闭浏览器跨域策略。

通过之前分析整个跨域模式是由前台浏览器的所作所为造成的。为了安全,浏览器对跨域请求做了一系列的验证。那是否可以想想, 通过手动关闭浏览器跨域策略是不是可以解决根本性的问题。

Mac 创建一个chrome.sh文件

#!/bin/bash
#!/bin/sh

open -a "Google Chrome" --args --disable-web-security  --user-data-dir

exit 0
复制代码

通过终端运行:

sh 加上chrome.sh文件地址
复制代码

注意: 在运行终端命令的时候,先检查是否已经启动过chrome,如果启动过需要手动关闭整个chrome的进程。

成功结果:

输入URL地址之后。所有的跨域问题会一并解决。

原理

虽然浏览器的跨域策略已经被关闭了。不存在任何浏览发送的跨域行为, 其内部原理正是因为浏览器会对简单的跨域请求做了拦截和复杂的跨域请求做了发送预检。

简单的跨域请求通过什么进行拦截?

在理解简单的跨域请求时先需要理解两个请求头的字段。

request请求头中的Origin

请求首部字段 Origin 指示了请求来自于哪个站点。该字段仅指示服务器名称,并不包含任何路径信息。该首部用于 CORS 请求。

通俗的说就是告诉服务器此时是从那个域名地址发送来的。只有在CORS的情况下Origin才会在请求头中出现。

request请求头中的HOST

Host 请求头指明了服务器的域名(对于虚拟主机来说),以及(可选的)服务器监听的TCP端口号。 如果没有给定端口号,会自动使用被请求服务的默认端口(比如请求一个HTTPURL会自动使用80端口)。 HTTP/1.1 的所有请求报文中必须包含一个Host头字段。如果一个 HTTP/1.1 请求缺少Host 头字段或者设置了超过一个的 Host 头字段,一个400(Bad Request)状态码会被返回。

通俗的说就是浏览器向服务端发送请求时, 所请求的服务器的域名地址。

响应头中的Access-Control-Allow-Origin

响应头指定了该响应的资源是否被允许与前台请求头给定的origin共享。

结论

所以跨域请求返回浏览器之后。虽然数据会返回但是。浏览器会比对请求头中的Origin与响应头中的Access-Control-Allow-Origin是否是共享匹配,如果不匹配。浏览器的xhr会捕获错误并且在浏览器端控制台抛出错误。并不能拿到期望的数据。

复杂的请求浏览器是如何检测跨域的?

对于复杂的请求跨域, 浏览器一旦检测此发送的请求头存在属于复杂的跨域请求时, 首先会发送一个预请求, 请求头中包函着以下重要的内容:

  1. Access-Control-Request-Headers(如果有自定义头或者content-type类形不属于简单请求的类型的情况下才会出来)
  2. Access-Control-Request-Method(除了简单的请求方法才会出现)

并且在发送预检请求时并不会把请求数据和cookie信息带入请求信息中。

什么是预检请求?

CORS中会使用 OPTIONS 方法发起一个预检请求(preflight request), 以获知服务器是否允许该实际请求。"预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

当浏览器请求头中发出request-Header或者request-Method时。此时服务端需要同意这两个请求头中对应的信息通过允许。需要在响应返回的时候对响应头做出响应处理。需要对Access-Control-Allow-MethodsAccess-Control-Allow-Headers设置。

原理图:

附带Credentials(身份凭证的)请求属于简单的跨域请求还是复杂的跨域请求?

关于CredentialsCORS中原理性已经讲的很明白了。但是这里想讲的就是在xhrCredentials设置为true时。此时只是简单的跨域请求,不会发送预检(OPTIONS)请求, 如果此时是复杂的跨域请求。会发送预检(OPTIONS)请求。

所以Credentials是否会发送预检,主要需要通过其它请求头的判定来决定是否需要发送预检。

原理图:

总结:

只有当request请求头与response返回头一一对应上了。互相允许通过共享策略。对于简单的跨域请求则不会被捕获错误.对于复杂的跨域请求则会发送真正的请求。同时会把cookie等传输数据带入请求体中。所以说关闭浏览器跨域策略就是关闭了浏览器对响应Origin头匹配时不再捕获,同时也会关闭对应的OPTIONS预检请求。直接发送给对应的后台服务器。所以说本质上虽然存在跨域,但是服务端永远是返回数据。一切的错误或者没有发送真正的请求都是浏览器的安全机制所为。

如何通过代理劫持机制解决跨域?

前面我们已经知道浏览器向服务器请求是存在跨域问题,但是服务器向服务器发送请求是不存在跨域问题。通过MS(middle server)进行请求劫持之后,通过服务端向服务端发送请求,再二次返回给浏览器端。

示意图:

在各大框架中都通过脚手架启动node服务承载着项目。例如vue-cli中就利用了http-proxy-middle进行一个请求的代理拦截,向目标服务器发送请求来解决跨域问题。

// 通过express启用3000端口

// index.html
<script>
  let url = '/api/getUser';
  let xhr = new XMLHttpRequest();
  xhr.open('post', url, true);
  xhr.setRequestHeader('content-type', 'application/json');
  xhr.setRequestHeader('X-Customer-Header', 'value');
  xhr.send();
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4) {
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        console.log(1)
        console.log(xhr.response)
      }
    }
  }
  
</script>


const proxyOption = {
	target: 'http://localhost:4000',
	pathRewrite: {
        '^/api/' : '/'  // 重写请求,api/解析为/
    },
    changeOrigin:true
};

app.use('/api', proxy(proxyOption))

复制代码
// 后台服务启动4000端口
app.post('/getUser', (req, res, next) => {
  res.send({
    code: 1
  })
})
复制代码

3000端口的静态文件发送ajax请求的时候,本身就是在一个域名下,不会造成任何跨域问题,同时会被app.use('/api/')捕获拦截,同时改写url地址向服务端4000端进行请求发送数据。此时就是server端与server端的请求通信。当4000端口的server接收到请求之后把数据返回给3000端口的server端,同时再返回给请求的ajax

用node原生API如何实现?

app.use('/api', (req, res) => {
  const reqHttp = http.request({
    host: '127.0.0.1',
    path: '/getUser',
    port: '4000',
    method: req.method,
    headers: req.headers
  }, (resHttp) => {
    let body = ''
    resHttp.on('data', (chunk) => {
      console.log(chunk.toString())
      body += chunk
    });
    resHttp.on('end', () => {
      res.end(body)
    });
  })
  reqHttp.end()
});
复制代码

以上代码本质上是模拟了代理劫持的方式,同时当拦截到url开头以/api起始的请求之后,通过node原生http模块的request方法向对应的后台发送请求,同时把浏览器请求过来的一些请求体,请求头等数据一并传给server端。通过http模块监听的结束方法最后把数据再返回到client浏览器端。这样形成了二次转方式解决跨域问题。整体就是利用了服务端向服务发送请求不会有跨域策略的限制,就是所谓的同源策略。因为浏览器会做options等预检的检测,而服务端并不会。

关注下面的标签,发现更多相似文章
评论