跨域

在说跨域之前,我们先来说下它是如何诞生的。

同源策略由网景公司提出,为了帮助阻隔而已文档,减少可能被攻击的媒介。如果两个 URL 的协议端口号主机名都相同,则认为这两个 URL 是同源。

如果缺少了同源策略,则浏览器很容易受到 XSS,CSRF 等攻击。

浏览器如何判断跨域

浏览器不会根据请求域名对应的 IP 地址是否相同来判断,而只会通过URL 首部信息来判断。因此,即便两个不同的域名都指向同一个 IP 地址,也属于跨域。

跨域请求从发送到被拦截的过程?

跨域请求并不是在请求发出的时候被拦截,服务器可以正常收到请求并返回结果,但这个结果被浏览器拦截了。

浏览器发现 AJAX 是跨域请求后,会先发一个谓词为 OPTIONS 的请求给服务器,这个请求叫“预检请求”(Preflighted);预检通过后才会二次发送原本真正的请求。

预检时会检测响应头中(访问控制允许字段)包含 Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers 等等相关字段,有一个不符合就失败了。

Notice

并非所有跨域 AJAX 请求都一定会有预检请求,只有复杂请求才会有。简单请求的话,不会发送 options 请求,直接检查字段。

为什么表单可以发跨域请求

因为跨域本身是为了阻止用户读取另一个域名下的内容,Ajax 可以获取响应,浏览器认为这不安全,所以拦截了响应。但是表单并不会获取新的内容,所以可以发起跨域请求。同时也说明了跨域并不能完全阻止 CSRF,因为请求毕竟是发出去了。

JSONP

原理

由民间提出,利用 <script></script> 标签没有跨域限制的机制请求其他源并通过注册的回调函数获取响应数据。同时一定需要后台服务器提供支持

JSONP 优点

  1. 兼容性好,不需要 XMLHttpRequestActiveX 的支持
  2. 在请求完毕后可以通过调用 callback 的方式回传结果

JSONP 缺点

  1. 只能实现 GET 请求
  2. 不能解决不同域的两个页面之间如何进行 JavaScript 调用的问题
  3. JSONP 在调用失败的时候不会返回各种 HTTP 状态码
  4. 有一定的安全风险

实现

  1. 准备一个全局接收函数
window.myCallback = (res) => {
  //声明一个全局函数 'callback',用于接收响应数据
  console.log(res)
}
  1. 动态创建 script 标签,发出请求
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 arr = []
    for (let key in params) {
      arr.push(`${key}=${params[key]}`)
    }
    script.src = `${url}?${arr.join('&')}`
    document.body.appendChild(script)
  })
}
jsonp({
  url: 'http://www.XXXXX.com/getData',
  params: { content: '123' },
  callback: 'myCallback',
}).then((data) => {
  console.log(data)
})

这样相当于发送了一个 http://www.XXXXX.com/getData?content=123&callback=myCallback 请求,后台响应并返回 myCallback('{"data":"[]"}'),这样就会执行我们全局定义的回调函数打印出 {data: []}

CORS

CORS(跨源资源共享 cross-origin sharing) 需要浏览器和后端同时支持

浏览器会自动进行 CORS 通信,实现 CORS 通信的关键是后端。只要后端实现了 CORS,就实现了跨域。

服务端设置 Access-Control-Allow-Origin 就可以开启 CORS。该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。

虽然设置 CORS 和前端没什么关系,但是通过这种方式解决跨域问题的话,会在发送请求时出现两种情况,分别为简单请求和复杂请求

简单 CORS 请求

简单请求需要满足:

  • 请求方式为 HEAD、POST 或者 GET。
  • http 头信息不超出以下字段:AcceptAccept-LanguageContent-LanguageLast-Event-IDContent-Type(限于三个值:application/x-www-form-urlencodedmultipart/form-datatext/plain)。

在简单请求的条件下,浏览器会自动在头部信息中添加一个 Origin 字段表示请求源头。Access-Control-Allow-Origin 字段表示该资源可以被任意外部域访问。

如果 Origin 源头不允许跨域访问,那么服务器会返回正常的 HTTP 响应,响应头不包含 Access-Control-Allow-Origin,于是抛出错误,但是响应的状态码可能是 200。

如果允许跨域访问,响应头会有如下字段:

  • Access-Control-Allow-Origin 允许来自哪个源的请求,要么是 Origin 的值,要么是 *
  • Access-Control-Allow-Credentials 布尔值,表示是否允许发送 Cookie,默认为 true。
  • Access-Control-Expose-Headers 哪些首部可以作为响应的一部分暴露给外部。XHMHttpRequest 对象的方法只能够拿到六种字段: Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma,如果想拿到其他的需要使用该字段指定。

非简单 CORS 请求

非简单请求需要满足:

  • 请求方式为 PUT、DELETE、CONNECT、OPTIONS、TRACE 或者 PATCH。
  • 人为设置了 CORS 相关字段之外的其他首部字段,如 Content-TypeContent-Language等等。
  • Content-Type 不属于 application/x-www-form-urlencodedmultipart/form-datatext/plain

当浏览器发现这个请求不是一个简单请求,就会发出一个预先检测的 OPTIONS 请求,询问服务器是否可以接受这样的请求。

请求头中同样包含 Origin 字段,如下:

OPTIONS /cors HTTP/1.1
Origin: localhost:2333
Access-Control-Request-Method: PUT // 表示使用的什么HTTP请求方法
Access-Control-Request-Headers: X-Custom-Header // 表示浏览器发送的自定义字段
Host: localhost:2332
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
User-Agent: Mozilla/5.0...

如果请求被服务器拒绝,也会返回正常的 HTTP 回应,但不包含任何 CORS 相关头信息,浏览器就会报错。

正常预检成功的响应头:

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://localhost:2332
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

一旦通过了 OPTIONS 请求,之后的每次 CORS 请求都和简单请求一样,服务器将响应:

Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400

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

因此浏览器整个跨域检测流程如下:

CORS 优点

  1. 支持所有类型的 HTTP 请求。
  2. CORS 可以通过 onerror 事件监听错误,并且浏览器控制台会看到报错信息。
  3. 相对 JSONP 更安全。

CORS 缺点

  1. 兼容性差。
  2. 对于复杂请求,CORS 会发两次请求。

postMessage

window.postMessage() 方法可以安全地实现跨源通信,该方法是 html5 新引进的特性,可以使用它来向其它的 window 对象发送消息,无论这个 window 对象是属于同源或不同源。

postMessage 适用于:

  • 页面和其打开的新窗口的数据传递(执行 window.open() 会返回新窗口的 window 引用,而新窗口可以通过 window.opener 获取到打开它的窗口)。
  • 多窗口之间消息传递。
  • 页面与嵌套的 iframe 消息传递。
  • 上面三个问题的跨域数据传递。
otherWindow.postMessage(data, origin, [transfer])
  • data 是将要发送给其他 window 的数据。
  • origin 用来指定哪些窗口能接收到消息时间,其值可以是字符串 * 或一个 URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配 origin 提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。
  • transfer 是一串和 data 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

举个例子 http://www.XXXXXX.com/a.html

<iframe src="http://doc.XXXXXX.com/b.html" id="iframe" onload="load()"></iframe>
<script>
  function load() {
    let iframe = document.getElementById('iframe')
    iframe.contentWindow.postMessage('你好,我叫Lisa', 'http://doc.XXXXXX.com')
    window.onmessage = function (e) {
      console.log(e.data) // 你好Lisa,我是Tom
    }
  }
</script>

http://doc.XXXXXX.com/b.html

window.onmessage = function (e) {
  console.log(e.data) // 你好,我叫Lisa
  e.source.postMessage('你好Lisa,我是Tom', e.origin)
}

WebSocket

WebSocket 是 HTML5 的一个持久化的协议,它实现了浏览器与服务器的双工通信。同时也是跨域的一种解决方案。WebSocket 属于 TCP 应用层协议,是一种全双工通信协议。WebSocket 在建立连接的时候需要借助 HTTP 协议建立好之后的通信就与 HTTP 无关了

一般使用第三方库 socket.io,socket.io 封装了原生的 WebSocket,提供了更简单、灵活的接口,也对不支持 webSocket 的浏览器提供了向下兼容。

为什么 WebSocket 可以跨域?

因为 WebSocket 根本不附属于同源策略,而且它本身就有意被设计成可以跨域的一个手段。由于历史原因,跨域检测一直是由浏览器端来做,但是 WebSocket 出现以后,对于 WebSocket 的跨域检测工作就交给了服务端,浏览器仍然会带上一个 Origin 跨域请求头,服务端则根据这个请求头判断此次跨域 WebSocket 请求是否合法。

Node 中间件代理

同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略,代理服务器,需要做以下几个步骤:

  1. 接受客户端请求。
  2. 将请求转发给服务器。
  3. 拿到服务器响应数据。
  4. 将响应转发给客户端。

因此这其中涉及到了两次跨域。

Nginx 反向代理

Nginx 代理类似于 Node 中间件代理,只不过你需要配置一个 Nginx 服务器转发请求。

Nginx 接收到外部对它的请求,再类似浏览器地址栏一样去请求某个接口。最后将请求到的内容返回回去,也就是说 Nginx 是做代理接口,它去请求实际服务器,在将数据返回给我们吗?不一定会修改发送方的 header 值,可以进行修改。

具体流程如下:

Browser => host => Nginx => 目标地址

服务器数据 => Nginx => Browser

也就是说,Nginx 并不是通过监听 Browser 的请求。而是作为一个服务器,接收外部对本机的请求。所以是先通过 host,让请求指向本机,才会经过 Nginx。才能进行转发。

Nginx 代理支持所有浏览器,支持 session,不需要修改任何代码,并且不会影响服务器性能。只需要修改 Nginx 的配置即可解决跨域问题。可以说是最简单的跨域方式。

window.name + iframe

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

window.name = "hello world"
window.location = "http://www.baidu.com"
// 跳转完毕后输出window.name仍然是hello world

其中 a.html 和 b.html 是同域的,都是 http://localhost:3000 而 c.html 是 http://localhost:4000

因此我们可以先加载同域 iframe,设置 iframename 属性,然后再切换 iframesrc 到不同域,那么不同域的这个 iframe 也可以访问到这个 name 属性了。

<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>

http://localhost:4000/c.html

<script>
  window.name = '这是数据'
</script>

使用这种方式的优势在于既解决了跨域问题,同时操作又是安全的。

location.hash + iframe

实现原理: a.html 欲与 c.html 跨域相互通信,通过中间页 b.html 来实现。 三个页面,不同域之间利用 iframe 的 location.hash 传值,相同域之间直接 javascript 访问来通信。

实现

首先:

  1. a.html,起在 localhost:3000 上。
  2. b.html,起在 localhost:3000 上。
  3. c.html,起在 localhost:4000 上。

可见 a 和 b 是同域的,c 是独立的。

实现步骤如下:

  1. a.html 给 c.html 传一个 hash 值。
  2. c.html 收到 hash 值后,再把 hash 值传递给 b.html。
  3. b.html 将结果放到 a.html 的 hash 值中。
<!-- a.html -->
<iframe src="http://localhost:4000/c.html#hello"></iframe>
<script>
  window.onhashchange = function () {
    //检测hash的变化
    console.log(location.hash)
  }
</script>
// c.html
console.log(location.hash) // hello
let iframe = document.createElement('iframe')
iframe.src = 'http://localhost:3000/b.html#hi!'
document.body.appendChild(iframe)
<!-- b.html -->
<script>
  window.parent.parent.location.hash = location.hash
  //b.html将结果放到a.html的hash值中,b.html可通过parent.parent访问a.html页面
</script>

document.domain + iframe

该方式只能在二级域名相同的情况下使用:如 a.test.comb.test.com 相通信。

只需要给页面添加 document.domain ='test.com' 表示二级域名都相同就可以实现跨域。

我们看个例子:页面 a.zf1.cn:3000/a.html 获取页面 b.zf1.cn:3000/b.htmla 的值。

<!-- a.html -->
<body>
  <iframe src="http://b.zf1.cn:3000/b.html" frameborder="0" onload="load()" id="iframe"></iframe>
  <script>
    document.domain = 'zf1.cn'
    function load() {
      const iframe = document.getElementById('iframe')
      console.log(iframe.contentWindow.a)
    }
  </script>
</body>
<!-- b.html -->
<body>
  <script>
    document.domain = 'zf1.cn'
    var a = 100
  </script>
</body>

参考文章