跨域问题

7/2/2020 HTTPNginxHTMLJavascript

那么多标签的一篇文章,一看就知道来头不小啊

# 何为跨域

  • 协议不同
    • http 和 https
  • 域名不同
    • test.com a.test.com b.test.com 三个的域名也不一样
  • 端口不同
    • 端口就是服务端放行的端口,通常 http 是 80/8080 端口https 则是 443 端口

# JSONP

jsonp 原理

  • 首先是利用 script 标签的 src 属性来实现跨域
  • 客户端注册 callback 方法名,携带在 URL 上
  • 服务器响应后生成 json, 将 json 放在刚才接收到的 callback 的函数中,就生成一段 getData({...对应返回值})
  • 客户端浏览器将 script 标签插入 DOM,解析 script 标签后,会执行 getData({...对应返回值})。 由于使用 script 标签的 src 属性,因此只支持 get 方法 客户端代码

示例代码:

客户端

<body>
  <button class="get">get data</button>

  <script>
    const btn = document.querySelector('.get')
    btn.addEventListener('click', function() {
      const script = document.createElement('script')
      script.setAttribute('src', 'http://127.0.0.1:8080/getData?callback=getData')
      document.head.appendChild(script)
      document.head.removeChild(script)
    })
    function getData(news) {
      console.log(news)
    }
  </script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

服务端














 










const http = require('http')
const fs = require('fs')
const path = require('path')
const url = require('url')

http
  .createServer(function(req, res) {
    const pathObj = url.parse(req.url, true)
    switch (pathObj.pathname) {
      case '/getNews':
        const news = [{ id: 678 }]
        res.setHeader('Content-type', 'text/json; charset=utf-8')
        if (pathObj.query.callback) {
          res.end(pathObj.query.callback + '(' + JSON.stringify(news) + ')')
        } else {
          res.end(JSON.stringify(news))
        }
        break
      default:
        res.writeHead(404, 'not found')
    }
  })
  .listen(8080)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

注意服务端这边的返回值:直接返回一个函数给前端执行,触发前端的 callback 方法。其实就是利用回调函数的特性

# 小结

  • JSONP 只能发送 GET 请求
  • 服务端需要根据不同的参数,返回不同的 JS 代码,如果是普通请求,返回对应的 JSON 数据,如果有特殊的参数callback。则返回这段参数的回调函数

# CORS

原理

跨域资源共享(CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器 让运行在一个 origin (domain) 上的 Web 应用被准许访问来自不同源服务器上的指定的资源。跨域资源共享( CORS )机制允许 Web 应用服务器进行跨域访问控制,从而使跨域数据传输得以安全进行。

对于 get 以外的请求,浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。 整个过程浏览器自动完成,服务器会添加一些附加的头信息, 因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口 (允许跨域请求),就可以跨源通信。

# CORS 的简单请求

只要同时满足以下两大条件,就属于简单请求。不满足的就是非简单请求

1、 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

2、HTTP 的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值 application/x-www-form-urlencodedmultipart/form-datatext/plain

# 简单请求

对于简单请求,浏览器直接发出 CORS 请求。具体来说,就是在头信息之中,增加一个 Origin 字段。

GET /cors HTTP/1.1
Origin: http://test.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
1
2
3
4
5
6

如果 Origin 指定的源,不在许可范围内,服务器会返回一个正常的 HTTP 回应。浏览器发现,这个回应的头信息没有包含 Access-Control-Allow-Origin 字段(详见下文),就知道出错了,从而抛出一个错误,被 XMLHttpRequest 的 onerror 回调函数捕获。注意,这种错误无法通过状态码识别,因为 HTTP 回应的状态码有可能是 200。

Access-Control-Allow-Origin: http://test.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8
1
2
3
4

Access-Control-Allow-Origin

该字段是必须的。它的值要么是请求时 Origin 字段的值,要么是一个 *,表示接受任意域名的请求。

Access-Control-Allow-Credentials

该字段可选。它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS 请求之中。设为 true,即表示服务器明确许可,Cookie 可以包含在请求中,一起发给服务器。这个值也只能设为 true,如果服务器不要浏览器发送 Cookie,删除该字段即可。

Access-Control-Expose-Headers

该字段可选。CORS 请求时,XMLHttpRequest 对象的 getResponseHeader()方法只能拿到 6 个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在 Access-Control-Expose-Headers 里面指定。上面的例子指定,getResponseHeader('FooBar')可以返回 FooBar 字段的值。

withCredentials 属性

上面说到,CORS 请求默认不发送 Cookie 和 HTTP 认证信息。如果要把 Cookie 发到服务器,一方面要服务器同意,指定 Access-Control-Allow-Credentials 字段。

另一方面,开发者必须在 AJAX 请求中打开 withCredentials 属性。

var xhr = new XMLHttpRequest()
xhr.withCredentials = true
1
2

需要注意的是

如果要发送 Cookie,Access-Control-Allow-Origin 就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie 依然遵循同源政策,只有用服务器域名设置的 Cookie 才会上传,其他域名的 Cookie 并不会上传,且(跨源)原网页代码中的 document.cookie 也无法读取服务器域名下的 Cookie。

# CROS 的非简单请求

除了上面说的简单请求,其他都是非简单请求,非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为"预检"请求(preflight)。

"预检"请求用的请求方法是 OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是 Origin,表示请求来自哪个源。

Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000
1
2
3
4

Access-Control-Allow-Methods 该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次"预检"请求

Access-Control-Allow-Headers 如果浏览器请求包括 Access-Control-Request-Headers 字段,则 Access-Control-Allow-Headers 字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在"预检"中请求的字段。

Access-Control-Allow-Credentials 该字段与简单请求时的含义相同。

Access-Control-Max-Age

该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是 20 天(1728000 秒),即允许缓存该条回应 1728000 秒(即 20 天),在此期间,不用发出另一条预检请求

# Nginx 反向代理

nginx 中的反向代理和 vue-cli 里面的 devServer.proxy 原理是一样的

比如现在我们在 a.test.com 域名下。需要请求 b.test.com/api/xxx
直接发起请求的话,请求的路径就是 b.test.com/api/xxx。那么我们换个思路:我们请求a.test.com/proxyB/api/
注意多加了一层 proxyB 实际上这个在原来的后端接口中是没有的。那么我们可以通过 nginx 的代理,把这一层 proxyB 去掉。说白了proxyB 只是为了给 nginx 做一个标识,让他知道调用了proxyB的就进行代理,其他的还是按正常流程

上一段 nginx 配置






 
 
 











server {
    listen    80;
    server_name  a,test.com;
    access_log  logs/test.access.log;
    # 匹配以/proxyB/开头的请求
    location ^~ /proxyB/ {
        proxy_pass http://b.test.com;
    }
    location / {
        root html/a;
        index index.html index.htm;
    }
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

可以看到,我们在 location 添加了一个规则 ^~ 以什么开头的一个标记 /proxyB/ 匹配以 proxyB 开头的请求
proxy_pass 代理到 http://b.test.com 域名中去
/proxyB/ 之前的内容呢?相当于都被替换成了 http://b.test.com。而/proxyB/后面的内容不变,接在 proxy_pass 的路径后面。就变成了 http://b.test.com/api/xxx

效果就是

我们看到的请求的 url 是 a.test.com/proxyB/api/xxxx 而通过 nginx 代理后,请求的内容则是 http://b.test.com/api/xxxx。那就达到了,其实客户端看到的并不是跨域,而是同域名的请求,进而解决了跨域的问题。

现在非常流行的 node.js 来做服务端,也可以做类似的响应转发功能,通过服务端去转发接口(因为服务端没有域名的概念,可以请求其他域名的接口,拿到返回值后在返回给当前请求的接口)

# iframe

iframe 的解决方案中,分了好几种情况,每种情况适应性都不一致,可以根据自身业务场景再做选择

# 同一个主域下不同子域之间的跨域请求 - document.domain+iframe

同一主域下的不同子域名:指的就是 a.test.comb.test.com 的这种情况。他们的顶级域名都是 test.com。a,b 是 test.com 的子域名

demo 演示

  • a.test.com/index.html



 




 




<body>
  <iframe id="BIndex" src="http://b.test.com/index.html"></iframe>
  <script>
    document.domain = 'test.com'
    function aa(str) {
      console.log(str)
    }
    window.onload = function() {
      document.querySelector('#BIndex').contentWindow.bb('从a页面传过来的数据')
    }
  </script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
  • b.test.com/index.html


 



 



<body>
  <script>
    document.domain = 'test.com'
    function bb(str) {
      console.log(str) // 该方法会被执行,输出了 '从a页面传过来的数据'
    }
    parent.aa('bbb') // 该方法也会调用a.test.com/index的方法。输出了bbb
  </script>
</body>
1
2
3
4
5
6
7
8
9

小结

  • 如果是同一主域下,只要对应的页面都设置 document.domain 。2 个页面之间的 (普通页面和 iframe)还是可以直接通信的
  • 如果是普通页面 (父页面) 调用 iframe 页面的方法,需要获取 iframe 对象,使用 contentWindow 去拿到 iframe 页面的 window,才能调用下面的方法
  • iframe 想给父页面通信,直接 window.parent/window.top 就可以访问父页面/顶级父页面的方法了
  • 如果是同一域名(很多后台管理系统其实用的也是 iframe)或者是像这种同主域的,是可以直接通过上述的方式访问 dom 节点。不仅仅只是调用方法。为什么要强调这一点?因为出于安全考虑不同源的 iframe 是不能这样操作的,具体原因看下面一个解释

# 完全不同源 - postMessage

上面提到 因为出于安全考虑不同源的 iframe 是不能操作dom节点和获取对应页面的方法。基本上只能看,不能动
同是 iframe,为啥待遇差那么多?这是一个很有必要的安全性问题:

想象一下:有一个钓鱼网站,利用 iframe 嵌套了银行转账的页面/某宝的登录界面。界面上完全看不出来差别,如果这时候你登录成功了,而钓鱼网站可以直接操作 dom节点/拿到关键的 JS 方法。是不是就可以为所欲为,自动帮你点击转账?自动发起各种请求和操作 所以处于安全考虑,不同源的都不能操作 dom 节点,也不能调用 JS 方法等

但是也不能排除,这 2 个域名的确都是自家的网站,2 个站点之间需要数据互通/交互咋办呢,于是 HTML5 推出了 window.postMessage方法。看下 MDN 的解释:

window.postMessage() 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为 https),端口号(443 为 https 的默认值),以及主机 (两个页面的模数 Document.domain 设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

发送消息给指定的页面

  • 语法 : otherWindow.postMessage(message, targetOrigin, [transfer]);
    • otherWindow : 就是要给哪个 iframe/页面传数据,第一个参数并不是当前的 window,而是要进行通信的 window 对象
    • message : 需要传输的数据,这里传输的数据并不限制类型(文本,JSON,甚至文件类型,都可以)
    • targetOrigin : 指定哪些窗口可以收到数据 其值可以是字符串"*"(表示无限制)。这个用处在哪呢?用于确保 iframe 当前的 URL 和我们想传递数据的 URL 一致,因为在 otherWindow 中,我们也只能通过 dom 找到对应的 iframe 节点可是对于他这时候的 URL(不同源情况下)我们是获取不到,也不能修改的。如果我们需要传递一些敏感信息,就需要指定 URL 去传递,必须 URL 和 otherWindow 匹配。那对应的 iframe 才会收到这条数据,确保安全性
    • transfer (非必填) : 是一串和 message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

有发送,那就会有接收

window.addEventListener('message', receiveMessage, false)
function receiveMessage(event) {
  var origin = event.origin
  if (origin !== 'btest.com') return
}
1
2
3
4
5
  • message 这是固定的写法了。添加一个监听,监听 message 消息,特指 iframe 的 postMessage 消息

  • 最后一个参数 false 貌似也是固定写法了,没去深究~

  • receiveMessage 要触发的方法,当然也可以写匿名函数,直接写在监听里面

    • 主要讲一下方法中的event参数
    • event.data : 从其他 window 传过来的数据。就是 postMessage 中的第一个参数 message
    • event.origin : 调用 postMessage 时消息发送方窗口的 origin。就是标记这个调用的来源
    • event.source : 对发送消息的窗口对象的引用; 您可以使用此来在具有不同 origin 的两个窗口之间建立双向通信

我们改一下演示的栗子:现在是 atest.com 要和 btest.com 实现通信

  • atest.com/index.html






 




<body>
  <iframe id="BIndex" src="http://btest.com/index.html"></iframe>
  <script>
    window.onload = function() {
      document
        .getElementById('BIndex')
        .contentWindow.postMessage({ fn: 'sayHi', name: 'Jioho' }, 'http://btest.com/index.html')
    }
  </script>
</body>
1
2
3
4
5
6
7
8
9
10
  • btest.com/index.html




 
 
 
 
 









<body>
  <script>
    window.addEventListener(
      'message',
      function(event) {
        if (event.data && event.data.fn) {
          window[event.data.fn](event.data)
        }
      },
      false
    )

    function sayHi(data) {
      console.log('my name is ' + data.name)
    }
  </script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

小结

  • 在不同源情况下,需要用 window.postMessage() 方法进行页面的通信
  • 通过 window.addEventListener('message')方法来接收通信事件
  • 不管是哪种方式的事件通信,都是基于一个回调事件进行
  • 上面的例子中,由于 window.postMessage() 触发的都是同一个 message 事件,所以我们可以在 data 参数中去定义一些回调函数名称,数据之类的,达到我们想要的效果

# 使用 meta 标签解决跨域

这是最简单粗暴的方式,因为跨域问题源自于浏览器记录了我们的 URL 来源

那我们直接发起请求的时候,让他们不记录,就不存在这个问题了~

方法也很简单,在 head 标签 或者在对应的 HTML 页面 加上 <meta content="no-referrer" name="referrer"> 就可以了

不过有个弊端就是,这样一加,安全性肯定有一定的威胁,其次就是自己的接口也不知道请求来源了~。所以,调试用可以,不过上线还是得自己评估风险

Last Updated: 5/9/2021, 10:45:03 PM