前端跨域问题及解决方案

1,450 阅读10分钟

同源策略

同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到 XSS、CSFR 等攻击。

同源策略限制内容有:

  • Cookie、LocalStorage、IndexedDB 等存储性内容
  • 对 DOM 节点的访问与操作
  • AJAX 请求不能发送

所谓同源是指**"协议+域名+端口"**三者相同,即便两个不同的域名指向同一个 ip 地址,也非同源。当协议、子域名、主域名、端口号中任意一个不相同时,都算作不同域。不同域之间相互请求资源,就算作“跨域”。

image.png

image.png

image.png

有一点必须要注意:跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。之所以会跨域,是因为受到了同源策略的限制,同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。同时也说明了跨域并不能完全阻止 CSRF,因为请求毕竟是发出去了。

几种跨域方式及其实现原理

有三个标签天然可以跨域的

  • img
  • link
  • script

这三个标签都需要访问静态资源,因此不受同源策略的限制,这很好理解。下文中的某些方法就是利用了这些标签的特性。

1. JSOP

JSONP = JSON + Padding,它利用了 <script> 元素的这个开放策略,网页可以得到从其他来源动态产生的 JSON 数据。JSONP 请求一定需要对方的服务器做支持才可以。

优点:

  • 简单
  • 兼容性好

缺点:

  • 只能支持 GET 方法
  • 不安全,可能会遭受 XSS 攻击。详见 XSS | 0x02 Security 中的 JSONP XSS:

2. 跨域资源共享(CORS)

CORS,Cross-origin resource sharing,跨域资源共享,即通过请求另一个域的资源来加载本网页资源的现象。它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 AJAX 只能同源使用的限制。

整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与同源的 AJAX 通信没有差别,代码完全一样。浏览器一旦发现 AJAX 请求跨源,就会自动添加一些附加的头信息有时还会多出一次附加的请求,但用户不会有感觉。

因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨源通信。

浏览器将 CORS 请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。

简单请求

只要同时满足以下两大条件,就属于简单请求:

(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 字段。 下面是一个例子,浏览器发现这次跨源 AJAX 请求是简单请求,就自动在头信息之中,添加一个 Origin 字段。

GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin 字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

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

如果 Origin 指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。

Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8
  • 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 字段的值。

上面说到,CORS 请求默认不发送 Cookie 和 HTTP 认证信息。如果要把Cookie发到服务器:

  1. 要服务器同意,指定 Access-Control-Allow-Credentials 字段。
  2. 开发者必须在 AJAX 请求中打开 **withCredentials** 属性。
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

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

非简单请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是 PUT 或 DELETE,或者 Content-Type 字段的类型是 application/json

非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为**"预检"请求(preflight)**。

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

除了 Origin 字段,"预检"请求的头信息包括两个特殊字段。

  • Access-Control-Request-Method:该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,上例是PUT。
  • Access-Control-Request-Headers:该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段,上例是X-Custom-Header。

服务器收到"预检"请求以后,检查了Origin、Access-Control-Request-Method 和 Access-Control-Request-Headers 字段以后,确认允许跨源请求,就可以做出****回应

如果浏览器否定了"预检"请求,会返回一个正常的 HTTP 回应,但是没有任何 CORS 相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被 XMLHttpRequest 对象的 onerror 回调函数捕获。控制台会打印出如下的报错信息。

一旦服务器通过了"预检"请求,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样,会有一个 Origin 头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin 头信息字段。

3. postMessage

postMessage 是 HTML5 XMLHttpRequest Level 2 中的 API,且是为数不多可以跨域操作的 window 属性之一,它可用于解决以下方面的问题:

  • 页面和其打开的新窗口的数据传递
  • 多窗口之间消息传递
  • 页面与嵌套的 iframe 消息传递
  • 上面三个场景的跨域数据传递

postMessage()方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递。

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

看一个例子🌰:

// a.html
<iframe src = "http://localhost:4000/b.html"frameborder = "0"id = "frame"onload = "load()"></iframe>// 等它加载完触发一个事件
<script>
function load() {
    let frame = document.getElementById('frame') frame.contentWindow.postMessage('我爱你', 'http://localhost:4000') //发送数据
    window.onmessage = function(e) { //接受返回数据
        console.log(e.data) //我不爱你
    }
} 
</script>

// b.html 
window.onmessage = function(e) {
    console.log(e.data) //我爱你
    e.source.postMessage('我不爱你', e.origin)
}

4. websocket

Websocket 是 HTML5 的一个持久化的协议,它实现了浏览器与服务器的全双工通信,同时也是跨域的一种解决方案。WebSocket 和 HTTP 都是应用层协议,都基于 TCP 协议。但是 WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据。

5. Node 中间件代理(两次跨域)

原理:同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略

代理服务器.png

6. Nginx 反向代理

实现原理:类似于 Node 中间件代理,需要你搭建一个中转 nginx 服务器,用于转发请求。

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

实现思路:通过 nginx 配置一个代理服务器(域名与 domain1 相同,端口不同)做跳板机,反向代理访问 domain2 接口,并且可以顺便修改 cookie 中 domain 信息,方便当前域 cookie 写入,实现跨域登录。

7. img 标签打点

前文说到 标签也不受跨域限制,早年网站做 UV、PV 统计的时候,会用一像素点去做打点统计。此方法只能由浏览器向服务端发起请求,但无法处理响应,用来打点是一个不错的选择。

8. window.name + iframe

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

通过 iframe 的 src 属性由外域转向本地域,跨域数据即由 iframe 的 window.name 从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

<!-- a.html(http://localhost:3000/a.html) -->
<!-- b.html 为中间代理页,与 a.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>

<!-- c.html(http://localhost:4000/c.html) -->
<script> window.name = '我不爱你'</script>

9. location.hash + iframe

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

这里巧妙应用了 url 中的锚点,但是原理和 window.name 是类似的。

image.png

10. document.domain + iframe

该方式只能用于二级域名相同的情况下,比如 a.test.com 和 b.test.com 适用于该方式。只需要给页面添加 document.domain ='test.com'表示二级域名都相同就可以实现跨域。

实现原理:两个页面都通过 js 强制设置 document.domain 为基础主域,就实现了同域。

参考资料