深入理解跨域和最佳实践分享

2,350 阅读3分钟

跨域是前端开发中一个非常常见的问题,尤其是随着单页应用(Single Page Application, SPA)的兴起,前后端分离开发和部署,前端在本地开发和部署的过程中都会面临着跨域问题。我们再次聊聊跨域这个话题,以及项目中对跨域的一些实践经验,希望带来一些新的收获。

什么是跨域

首先我们需要了解下什么是同源策略,MDN 中是这样介绍的:

同源策略是浏览器的一个重要安全策略,它用于限制一个源的文档或者它加载的脚本如何能与另一个源的资源进行操作。它能帮助阻隔恶意文档,减少可能被攻击的媒介。

从这段话我们得知,同源策略是浏览器安全策略,(Origin)是判断是否满足同源策略的条件。我们常说的跨域,准确来说应该是跨源,目的是不受同源策略的限制去操作另一个源的资源。例如当我们在本地开发时,源是 http://localhost:3000,而服务端接口的地址是 https://api.feishu.cn,此时通过 Fetch API 访问接口就会产生跨域。

同源策略源自于浏览器,反之在非浏览器的环境下,一般是没有同源策略限制的。例如在 Node.js 中,我们可以请求任意的网址并得到结果。因此基于 Node.js 的服务端可以直接跨域访问资源。

当然我们也可以关闭浏览器的同源策略,关闭后浏览器环境下也可以直接跨域。以 Chrome 为例,通过给 Chrome 增加启动参数:

$ /path/to/chrome.app --disable-web-security

即可关闭。关闭后浏览器会有安全风险,建议只用在本地开发上

源由协议域名(准确来说是主机名,因为除了域名也可以是 IP)、端口号共同决定,三者完全相同的两个 URL 会被认为是同源。举几个例子:

  • https://www.bytedance.comhttps://jobs.bytedance.com,域名不同,不同源
  • http://www.bytedance.comhttps://www.bytedance.com,协议不同,不同源
  • http://localhosthttp://localhost:8080,端口不同(80 和 8080),不同源

特别的,当我们直接打开一个 HTML 文件时,使用的是 file 协议,请求 HTTP 资源时协议不同,会产生跨域。

源是允许被有限度的修改,浏览器提供了 API 可以将子域名下的源修改为父域名的源,例如我们在 https://open.feishu.cn 下执行:

document.domain = 'feishu.cn';

此时页面的源就变成了 https://feishu.cn 满足同源策略,我们就可以直接访问父域名下的资源。如果修改为非父域名(如 bytedance.com),浏览器会报错。

同源策略控制不同源之间的操作,这些操作通常分为三类:

  • 资源嵌入:一般是被允许的。例如 <script><iframe> 引入资源
  • 写操作:一般是被允许的。例如链接、重定向以及表单提交,满足特定条件的 HTTP 请求不允许
  • 读操作:一般是不被允许的。例如 XMLHttpRequestFetch API 发起 GET 请求

根据这个分类,我们来重点讨论几种情况:

  • <script> 是资源嵌入不受同源策略控制,JSONP 就是借助这一特性实现的跨域
  • <form> 是资源写入不受同源策略控制,因此表单的 action 不是同源 URL 时也可以提交成功
  • XMLHttpRequestFetch API 是我们重点关注的,下面我们具体讨论下:
    • 当发起 HTTP GET 请求(也可以是其它类型)读取资源,受同源策略控制,请求返回的内容在不同源的情况下我们是读不到的
    • 当发起 HTTP POST 请求修改资源,一般是不受同源策略控制的(称为简单请求),资源是允许被修改;而对于非简单请求,受同源策略控制,资源不允许修改

需要特别说明的是:

  • HTTP GET 读取资源虽然受同源策略控制,但请求是成功发送的,只是浏览器限制了请求返回的内容不给我们而是抛出了错误
  • HTTP POST 写入资源时,简单请求也是发送成功的,因此资源被成功修改了,因此可以说写操作不受同源策略控制,但请求的返回值我们同样获取不到(因为是读操作)
  • 非简单请求采用了先发一个预检请求的方式,判断是否允许跨域,不允许则不会发送真实的请求,避免资源被修改,因此受同源策略控制。下面实践部分会详细讨论如何允许跨域。
  • WebSocket 不受同源策略控制

简单请求必须满足以下条件:

  • HTTP 方法是:GET POSTHEAD
  • 除了被浏览器自动设置的字段(例如 ConnectionUser-Agent),请求头只允许以下字段:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type:只允许值为 text/plain multipart/form-data application/x-www-form-urlencoded
    • Range:只允许简单的范围标头值,如 bytes=256- 或 bytes=127-255

不满足上述条件的即为非简单请求。例如当我们在请求头增加 X-JWT-TokenContent-Type: application/json 时,这个请求就是非简单请求。

为什么需要同源策略

上面我们说了,同源策略是一个安全策略。如果同源策略被关闭,个人信息将会有安全风险,例如:

  • 恶意站点可以调用其它网站的用户信息接口,获取敏感信息
  • 恶意站点可以调用其它网站的点赞或删除接口,恶意修改资源

跨域的最佳实践

那么我们如何才能跨域呢?浏览器在同源策略的基础上,提出一种可以安全的跨域机制称为跨源资源共享(Cross Origin Resource Sharing,CORS)。当然在这个机制发布之前,也有 JSONP 等满足浏览器安全机制的跨域方案,本文不具体讨论。

CORS 的使用很简单,服务端(即被请求的资源)在响应头中增加:

Access-Control-Allow-Origin: https://www.bytedance.com

即可,其中的值表示允许跨域访问的源,此时 https://www.bytedance.com 就可以访问这个服务器的资源。我们可以看出,请求是成功发送并得到响应的,浏览器才能拿到 Access-Control-Allow-Origin 响应头并判断是否可以跨域(先请求再判断)。那么对于非简单请求,为了避免资源被意外修改,是需要先判断是否可以跨域再发起修改请求的(先判断再请求),这时浏览器就会先发起一个预检请求(HTTP 方法为 OPTIONS),拿到服务端返回的 Access-Control-Allow-Origin 并判断,满足允许跨域的话再发起真实请求。

如果我们无法修改服务端呢?可以实现一个我们可控的代理服务端,增加允许跨域响应头或直接同域,解决跨域问题,例如通过 NginxCharleswebpack-dev-server 代理。

下面我们来讨论项目中几种常见的跨域场景和最佳实践:

跨域请求需要携带 Cookie

XMLHttpRequestFetch API 在发起跨域请求时默认是不携带服务端所在源的 Cookie,这样影响到了服务端的用户身份鉴别。我们需要增加参数,例如:

const xhr = new XMLHttpRequest();
xhr.withCredentials = true;

fetch(url, {
  credentials: 'include',
});

浏览器才会携带 Cookie,同时服务端需要返回响应头:

Access-Control-Allow-Credentials: true

浏览器才会正常将响应内容返回给我们,否则会抛出错误

允许白名单内的源跨域请求

当有多个源需要允许跨域访问时,服务端可以配置

Access-Control-Allow-Origin: *

允许所有源跨域访问,但开放范围太大有一定的安全隐患,同时这种情况下浏览器不允许携带 Cookie。我们需要对源精细化的控制,但 Access-Control-Allow-Origin 不允许设置多个源。我们可以通过请求头 Origin 加白名单判断的方式,动态返回 Access-Control-Allow-Origin 的值解决。

Origin 是跨域请求时浏览器自动携带的值,表示请求的源,我们在服务端定义一个白名单判断这个源是否在白名单中,如果在则返回 Access-Control-Allow-Origin 的值等于请求头的 Origin。以 express 为例代码如下:

const whiteList = ['https://jobs.bytedance.com', 'https://www.bytedance.com'];

app.get('/path/to/api', (request, response) => {
  const { origin } = request.headers;
  
  if (whiteList.includes(origin)) {
    response.header('Access-Control-Allow-Origin', origin);
    response.header('Access-Control-Allow-Credentials', true);
  }
});

获取跨域请求返回的某些响应头

跨域响应中的响应头默认并不是所有的都可以被我们获取到,默认浏览器只返回一些基本响应头,包括:

  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

当我们需要获取一些额外的响应头,例如 X-TT-LogID 用于记录每次请求的 logID,我们需要让服务端返回响应头:

Access-Control-Expose-Headers: X-TT-LogID

才可以获取到

获取资源使用简单请求

前面我们了解到,非简单请求会先发起预检请求以检查是否允许跨域,因此需要两次 HTTP 请求才能完成这次操作。对于我们已知是读操作的请求,我们可以尽量满足简单请求的条件以减少预检请求,从而减少请求时间。

一般来说,我们需要注意的地方如下:

  • 读取资源时尽量使用 GETPOST 请求
  • 对于 POST 请求且需要通过 body 传递 JSON 数据时,Content-Type 使用 text/plain 而不是 application/json,同时服务端也需要支持将 text/plain 解析成 JSON
  • 对于其它需要请求携带的信息,尽量放在请求参数中而不是自定义请求头,因为额外的请求头也会导致变成非简单请求,例如 JWT 的 X-JWT-Token

CDN 资源也需要允许跨域

如果我们接入了 SentryPerfsee 等前端监控平台并需要监控站点的 JS 脚本错误,且这些 JS 脚本是部署在 CDN 服务器上,由于 CDN 和我们的站点往往不同源,浏览器对于不同源的 <script> 资源嵌入不会返回具体的错误信息(读取错误信息属于读操作受同源策略控制),这样监控平台收集到的错误信息不完整,对定位问题带来了负面影响。

我们需要让 CDN 服务器也返回 Access-Control-Allow-Origin 头允许我们的源,同时所有引入 JS 的 <script> 需要增加 crossorigin 属性:

<script src="/path/to/cdn" crossorigin="anonymous"></script>

总结

我们从三个角度讨论了跨域是什么、为什么需要同源策略以及跨域的解决方案和实践,重点总结如下:

  • 同源策略是浏览器的安全策略,Node.js 环境没有跨域限制
  • 浏览器的同源策略通过启动参数 disable-web-security 可以关闭
  • 通过协议、域名、端口号三元组判断同源
  • 通过 document.domain 可以有限度的修改源
  • 简单请求与非简单请求的区别,非简单请求会先发起预检请求
  • 同源策略保证了浏览器的安全性
  • 跨域通过增加响应头 Access-Control-Allow-Origin 解决,也可以借助代理实现
  • 跨域的一些最佳实践:
    • 跨域请求通过 Access-Control-Allow-Credentials 携带 Cookie
    • 通过请求头 Origin 允许白名单内的源跨域请求
    • 通过 Access-Control-Expose-Headers 获取跨域请求返回的某些响应头
    • 获取资源尽量使用简单请求,以减少请求耗时
    • CDN 资源也需要允许跨域

如果有其它关于跨域的最佳实践,欢迎分享