常见的跨域解决方式

595 阅读11分钟

跨域解决方案

浏览器同源策略

同源策略是浏览器的安全策略

限制从一个源加载的文档或脚本如何与来自另一个源的资源进行交互,这是一个用于隔离潜在恶意文件的关键的安全机制

同源需要满足以下三个方面

  • 协议相同

  • 域名相同

  • 端口相同

使用www.a.com去发请求

如果不是同源的则会有如下限制

  • Cookie、LocalStorage 和 IndexDB 无法读取

  • DOM 无法获取

  • AJAX 请求不能发送

为什么要实现跨域请求?

工程服务化后,不同职责的服务分散在不同的工程中,往往这些工程的域名是不同的,但一个需求可能对应到多个服务,这时便需要调用不同服务的接口,因此会出现跨域。

同源策略仅存在于浏览器客户端,不存在 Android、iOS、NodeJS、Python、Java 等其他环境。

跨域请求能成功发送,服务端能接收请求并正常返回结果,只是结果被浏览器拦截了。

跨源网络访问

严格来说,浏览器并不是拒绝所有的跨域请求,实际上拒绝的是跨域的读操作。

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

  • 通常浏览器允许进行跨域 写操作(Cross-origin writes),如链接、重定向以及表单提交

  • 通常浏览器允许跨域 资源嵌入(Cross-origin embedding),如 img、script 标签

  • 通常浏览器不允许跨域 读操作(Cross-origin reads),但常可以通过内嵌资源来巧妙地进行读取访问。例如可以读取嵌套图片的高度和宽度,调用内嵌脚本的方法

如何解决跨域

  • cors跨域资源共享、大多数使用这个

  • Node代理跨域、我们去请求node服务、服务器之间不存在跨域的问题

  • nginx代理跨域、配置location、进行转发

  • JSONP 只支持get请求、无法满足post

  • websocket

  • window.postMessage

  • document.domain + iframe

  • window.location.hash + iframe

  • window.name + iframe

CORS

CORS 亦即 跨域资源共享,是一种 HTTP 机制,它使用额外的 HTTP 响应头来告诉浏览器让其运行在一个 origin (domain) 上的 Web 应用被准许访问来自 不同源服务器 上的指定的资源。当一个资源从与该资源本身所在的服务器 不同的域、协议或端口 请求一个资源时,资源会发起一个 跨域 HTTP 请求。

值得注意的是,通常使用 CORS 时,异步请求会被分为简单请求和非简单请求,非简单请求的区别是会先发送一个 预检请求(Preflight Request)。

Access-Control-Allow-Origin 只能阻止浏览器端拿到服务器返回数据,服务端的处理还是会执行,要配合 token 鉴权令牌等策略来防范。

原生实现

在node.js中cors、这里的node就是我们的服务端

app.use(async (ctx, next) => {
ctx.set('Access-Control-Allow-Origin', ctx.headers.origin);
// 设置为true 跨域的时候可以携带cookie
ctx.set('Access-Control-Allow-Credentials', true);
// 允许的请求方式
ctx.set('Access-Control-Request-Method', 'PUT,POST,GET,DELETE,OPTIONS');
ctx.set('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, cc');
});

koa、node的框架和egg类似

const Koa = require('koa');
const cors = require('koa2-cors'); // 使用插件解决跨域处理

const app = new Koa();

app.use(
  cors({
    origin: function(ctx) {
      if (ctx.url === '/login') {
        // 允许来自所有域名的请求
        return '*';
      }

      // 只允许 http://localhost:8080 这个域名的请求
      return 'http://localhost:8080';
    },
    // 指定本次预检请求的有效期,单位为秒
    maxAge: 5,
    // 是否允许发送 Cookie
    credentials: true,
    // 设置所允许的 HTTP 请求方法
    allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    // 设置服务器支持的所有头信息字段
    allowHeaders: ['Content-Type', 'Authorization', 'Accept'],
    // 设置获取其他自定义字段
    exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
  })
);

cookie的问题

要传递 Cookie 需要满足三个条件:

1、Web 请求设置 withCredentials

这里默认情况下在跨域请求,浏览器是不带 Cookie 的。但是我们可以通过设置 withCredentials 来进行传递 Cookie。

// 原生 XMLHttpRequest 的设置方式
const xhr = new XHRHttpRequest();
xhr.withCredentials = true;

// Axios 设置方式
axios.default.withCredentials = true;

2、Access-Control-Allow-Credentials 为 true

3、Access-Control-Allow-Origin 为非 *

这里的请求方式,在 Chrome 中是能看到返回值的,但是只要不满足以上其一,浏览器会报错,无法获取数据。

node代理

利用 Node + Express + Http-Proxy-Middleware 搭建一个 Proxy 服务器

前端实现

const xhr = new XMLHttpRequest();

// 前端开关:浏览器是否读写cookie
xhr.withCredentials = true;

// 访问http-proxy-middleware代理服务器
xhr.open('get', 'http://www.domain1.com:3000/login?user=admin', true);
xhr.send();

服务器实现

const express = require('express');
const proxy = require('http-proxy-middleware');
const app = express();

app.use(
  '/',
  proxy({
    // 代理跨域目标接口
    target: 'http://www.domain2.com:8080',
    changeOrigin: true,

    // 修改响应头信息,实现跨域并允许带cookie
    onProxyRes: function(proxyRes, req, res) {
      res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
      res.header('Access-Control-Allow-Credentials', 'true');
    },

    // 修改响应信息中的cookie域名
    cookieDomainRewrite: 'www.domain1.com', // 可以为false,表示不修改
  })
);

app.listen(3000);
console.log('Proxy server is listen at port 3000...');

本质上与 CORS 中列举的 Koa 的例子的原理是一样的,就是解除 CORS 的限制,但这里更强调的是开发时与后端接口联调的场景使用,即启动前端项目的同时,也启动一个开发本地的 Node 代理服务用于转发客户端向服务端发起的请求。

nginx 反向代理

Nginx 可实现用于反向代理的异步 Web 服务器,他除了用于反向代理以外还可以用于负载均衡、 HTTP 缓存。

http {
  include               mime.types;
  # 文件类型
  default_type          application/octet-stream;
  # 默认类型
  client_max_body_size  2000M;
  # 请求体最大值
  keepalive_timeout     65;
  # 请求最大延期时间

  # 虚拟服务器
  server {
    # 监听端口
    listen       8080;
    # 本机的域名
    server_name  localhost;

    # CORS 设置
    # 指定响应资源是否允许与给定的 origin 共享
    add_header Access-Control-Allow-Origin *;
    # 配置是否允许将对请求的响应暴露给页面
    add_header Access-Control-Allow-Credentials 'true';
    # 配置允许跨域的请求头
    add_header Access-Control-Allow-Headers 'Authorization,Content-Type,Accept,Origin,User-Agent,Cache-Control,X-Mx-ReqToken,X-Requested-With';
    # 配置允许跨域的请求方法
    add_header Access-Control-Allow-Methods 'GET,POST,Options';

    # 跳过服务端校验,直接返回 200
    # 预请求 就是在跨域访问的时候、在真正发出请求的时候 先去请求下服务器、当前这种请求方式是否可以请求
    if ($request_method = 'OPTIONS') {
      return 200;
    }

    location / {
        root        /data/example/dist;
        index       index.html index.htm;
        try_files   $uri /index.html;
        # 解决 vue history模式的 刷新404问题
        add_header  Cache-Control "private, no-store, no-cache, must-revalidate, proxy-revalidate";
        # 设置浏览器缓存的关键字
    }

    location /api/ {
        # 将 /api开头的api的请求更换域名
        proxy_pass  https://xxx.xxx.xxx/req/;
    }
  }
}
  • 对于简单请求,如 GET,只需要在 HTTP Response 后添加 Access-Control-Allow-Origin

  • 对于非简单请求,如 POST、PUT、DELETE 等,浏览器会分两次应答。第一次 Preflight(method: OPTIONS),主要验证来源是否合法,并返回允许的 Headers 等;第二次才是真正的 HTTP 应答,所以服务器必须处理 OPTIONS 应答。

由于浏览器第一次 Preflight 预检,导致服务端对其请求进行校验,但是服务端本身没有开启 CORS,所以会导致服务端返回 405 Method Not Allowed 或者 403 Forbidden。所以我们需要在 Nginx 对该请求进行拦截,直接返回 200,跳过服务端校验。

JSONP

动态创建 <script> 脚本标签,通过跨域脚本嵌入不受同源策略限制的方法实现请求第三方服务器数据内容。除了适用于 <script> 脚本标签,HTML 中包含 src 和 href 属性的标签均不受同源策略限制。

实现步骤

  • 动态创建script标签

  • 标签src属性设置接口地址

  • 接口参数、必须带一个自定义函数名、要不然无法返回数据、一般是callback或者cb

  • 通过定义函数名去接收后台返回的数据

前端实现

// 动态创建脚本标签
const script = document.createElement('script');

// 设置接口地址
script.src = 'http://localhost:8080/api/jsonp?cb=jsonpCallback';

// 插入页面
document.appendChild(script);

// 通过定义回调函数接收响应数据
window.jsonpCallback = function(res) {
  // do something with response data
};

后端实现

const Koa = require('koa');
const fs = require('fs');
const app = new Koa();

app.use(async (ctx, next) => {
  if (ctx.path === '/api/jsonp') {
    const { cb } = ctx.query;
    // 将需要返回的内容 当作参数传递给我们定义好的回调函数
    ctx.body = `${cb}(${JSON.stringify({ msg })})`;
    return;
  }
});

app.listen(8080);

由于 <script> 元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了回调函数,该函数就会立即调用。作为参数的 JSON 数据被视为 JavaScript 对象,而不是字符串,因此避免了使用 JSON.parse 的步骤。

缺点

  • 非常高的跨站脚本攻击风险

  • 只能实现GET请求

  • 接口出错时、很难进行错误识别处理

websocket

WebSocket 是一种通信协议,使用 ws://(非加密)和 wss://(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。

简单来说,WebSocket 通过客户端和服务端之间建立持久的连接,双方可以随时发送数据。

这种方式本质上没有使用 HTTP 协议进行通信,因此也没有浏览器跨域的限制。

前端部分

const socket = new WebSocket('ws://localhost:8080');

socket.onopen = function() {
  socket.send('Client Socket is openning');
};

socket.onmessage = function(e) {
  console.log(e.data);
};

后端部分

const WebSocket = require('ws');
const server = new WebSocket.Server({ port: 8080 });

server.on('connection', function(socket) {
  socket.on('message', function(data) {
    socket.send(data);
  });
});

window.postMessage

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

window.postMessage(message, targetOrigin) 方法是 HTML5 新引进的特性,可以使用它向其他的 window 对象发送消息,无论这个 window 对象是属于同源或不同源,目前 IE8+、Firefox、Chrome、Opera 等浏览器都已经支持该方法。

用途

  • 页面和其打开的新窗口的数据传递

  • 多窗口之间消息传递

  • 页面与嵌套的 iframe 消息传递

otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow:其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行 window.open 返回的窗口、或者是命名过或数值索引的 window.iframes

  • message:将要发送到其他 window 的数据

  • targetOrigin:通过窗口的 origin 属性来指定哪些窗口能接收到消息事件

  • transfer(可选):是一串和 message 同时传递的 Transferable 对象,这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权

发送端页面

<iframe id="iframe" src="http://localhost:8080/" frameborder="0" onload="load()"></iframe>

<script type="application/javascript">
  function load() {
    iframe.contentWindow.postMessage('Hello world!', 'http://localhost:8080/');

    window.onmessage = e => {
      console.log(e.data);
    };
  }
</script>

接收端

<div></div>
<script type="application/javascript">
  window.onmessage = e => {
    console.log(e.data);
    // Hello world!
    e.source.postMessage(e.data, e.origin);
  };
</script>

document.domain + iframe

Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。

但是,两个网页一级域名相同,只是二级域名不同,浏览器只需要设置 document.domain 为更高级别的域就能实现 Cookie 共享。

例子🌰

以 a.foo.com 和 b.foo.com 为例,只要设置相同的 document.domain,两个网页就可以共享 Cookie。

document.domain = 'foo.com';

这种方法只适用于 Cookie 和 iframe 窗口,localStorage 和 IndexDB 无法通过这种方法,规避同源策略,需要采用下文提及的 PostMessage API。

另外,服务器也可以在设置 Cookie 的时候,指定 Cookie 的所属域名为一级域名。

Set-Cookie: key=value; domain=.example; path=/
# 那么,二级域名和三级域名不用做任何设置,都可以读取这个 Cookie。

window.location.hash + iframe

如果两个网页不同源,就无法拿到对方的 DOM。典型的例子是 iframe 窗口和 window.open 方法打开的窗口,它们与父窗口无法通信。 通过 url 带 hash 锚点,可以通过一个非跨域的中间页面来传递数据。 一开始 a.html 给 c.html 传一个 hash 值,然后 c.html 收到 hash 值后,再把 hash 值传递给 b.html,最后 b.html 将结果放到 a.html 的 hash 值中。同样的,a.html 和 b.html 是同域的,都是 http://localhost:8000/,而 c.html 是 http://localhost:8080/。

阻止跨源访问

  • 阻止跨域写操作,只要检测请求中的一个不可测的标记(CSRF token)即可,这个标记被称为 Cross-Site Request Forgery (CSRF) 标记。必须使用这个标记来阻止页面的跨站读操作。

  • 阻止跨站嵌入,需要确保你的资源不能是以上列出的可嵌入资源格式。多数情况下浏览器都不会遵守 Content-Type 消息头。例如,如果您在 HTML 文档中指定 <script> 标记,则浏览器将尝试将 HTML 解析为 JavaScript。 当您的资源不是您网站的入口点时,您还可以使用 CSRF 令牌来防止嵌入。

  • 阻止资源的跨站读取,需要保证该资源是不可嵌入的。阻止嵌入行为是必须的,因为嵌入资源通常向其暴露信息。

补充、http cors 跨域资源共享

跨域资源共享(CORS:Cross-Origin Resource Sharing)是一种机制,它使用额外的 HTTP 头来告诉浏览器,让运行在相同域(Origin)上的 Web 应用被准许访问来自不同源服务器上的指定的资源(也即是 同源策略 的 HTTP 解决方案)。当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。

CORS 机制是为了在认可用户发起的请求的同时,阻止恶意注入脚本;并在以下情况发起的 HTTP 请求时触发:

  • 不同的协议:比如从 example.com 调用 example.com

  • 不同的域:比如从 example.com 调用 api.com

  • 不同的子域:比如从 example.com 调用 api.example.com

  • 不同的端口:比如从 example.com 调用 example.com:3001

实现条件

浏览器将 CORS 请求分成两类:

  • 简单请求(simple request)

  • 非简单请求(not-so-simple request)

简单请求

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

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

  • HEAD

  • GET

  • POST

2、自定义设置集合外的头部字段

  • Accept 告知服务端当前客户端可处理的内容类型

  • Accept-Language 允许客户端声明它可以理解的自然语言,以及优先选择的区域方言

  • Content-Language 说明访问者希望采用的的语言

  • Content-Type(例如 application/json 为非简单请求)指示资源的 MIME 类型

    • text/plain

    • multipart/form-data

    • application/x-www-form-urlencoded

  • DPR

  • Downlink

  • Save-Data

  • Viewport-Width

  • Width

  • Last-Event-ID

3、请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器

XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。

4、请求中没有 ReadableStream 对象。

非简单请求

  • PUT

  • DELETE

  • CONNECT

  • OPTIONS

  • TRACT

  • PATCH

最常用于判断是否为简单和非简单请求的方法主要是通过 请求方法 和 Content-Type 头部字段的值。

预请求 Preflight

预请求是 OPTIONS 请求,浏览器会自动添加 Access-Control-Allow-Headers 和 Access-Control-Allow-Methods 头部字段。

需要服务端返回的响应头 Access-Control-Allow-Headers、Access-Control-Allow-Methods 和 Access-Control-Allow-Origin。

除了 Access-Control-Allow-Origin 是必须的之外,其他两种只有在不符合简单请求需要的时候服务器才需要添加,比如在简单请求的基础上自定义了一个请求头 X-xx-name: chris,那么服务器只需要在响应头中添加 Access-Control-Allow-Headers。

减少预请求

可以通过设置 Access-Control-Max-Age 来减少预请求的次数,需要包含在预请求的响应头中,指定在该时间内预请求验证有效,不必每次都进行预请求,它的单位是 s。如 Access-Control-Max-Age: 1728000,即有效期为 20 天。

正常请求

预请求完之后就可以发送正常请求了,正常请求的步骤与简单请求一致,也需要添加 Access-Control-Allow-Origin 响应头。