跨域

tags: 前端工程化 summary: 本文介绍了前端开发中的跨域问题,首先介绍了浏览器的同源策略,然后介绍了几种常见的跨域方案,例如正向代理、反向代理、JSONP和CORS等,并给出相关代码示例。 Created time: December 24, 2022 8:42 PM emoji: https://s1.ax1x.com/2023/01/22/pSJh3dA.png

浏览器的同源策略

同源策略是一个重要的安全策略,它用于限制一个origin的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。所谓的同源是指协议、域名和端口号都相同,如果有一个不同都不算是同源。

浏览器限制脚本内发起的跨源 HTTP 请求。 例如,XMLHttpRequestFetch API遵循同源策略。这意味着使用这些 API 的 Web 应用程序只能从加载应用程序的同一个域请求 HTTP 资源,假如发送一个跨域请求,浏览器则会将响应结果丢弃,除非其符合CORS策略。

跨域方案

图片探测

虽然xhr对象和fetch API会受到浏览器的同源策略的限制,但是HTML本身是不会受到同源策略的影响的,因此我们可以用img标签来发送一个GET请求,设置display:none来隐藏元素,可以通过监听onload和onerror事件来判断请求是否发送成功。

利用img实现跨域要特别注意一点,图片是能够被缓存的,因此需要在图片的url后加上一个时间戳,例如:

img.src = url + '?t=' + new Date().getTime();

这种方法的缺点很明显,只能使用GET方法,且无法获取响应内容。

本地服务器代理

这是开发环境中最常用的一种跨域方法,在webpack或vite中进行相应的配置,然后由nodejs开发服务器代理请求转发给目标服务器,由于同源策略只在浏览器中起作用,从而绕开了同源策略的限制,开发服务器得到响应后再返回给浏览器。

// 以vite为例
export default defineConfig({
  server: {
    proxy: {
      // 字符串简写写法
      '/foo': 'http://localhost:4567',
      // 选项写法
      '/api': {
        target: 'http://jsonplaceholder.typicode.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      },
      // 正则表达式写法
      '^/fallback/.*': {
        target: 'http://jsonplaceholder.typicode.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/fallback/, '')
      },
      // 使用 proxy 实例
      '/api': {
        target: 'http://jsonplaceholder.typicode.com',
        changeOrigin: true,
        configure: (proxy, options) => {
          // proxy 是 'http-proxy' 的实例
        }
      }
    }
  }
})

反向代理

除了正向代理外,反向代理也可以解决跨域限制。反向代理解决跨域的思路是有两种

  1. 对反向代理服务器使用CORS,这种方法本质上仍然是CORS方法,但是它的意义在于设置好反向代理的跨域资源共享(CORS)后,所有目标服务器不用再做任何处理,只需要设置好反向代理即可。

  2. 利用反向代理将后端接口代理到与网站的同源路径下。

JSONP

JSONP全称是json with Padding,这是一种比较老的解决方案。

jsonp的思路是

  1. 首先定义好回调函数,后端的数据作为回调函数的参数。

  2. 动态创建script标签请求后端接口

  3. 后端返回回调执行语句,并将数据作为参数传过来。

// 服务器 nodejs
const http = require("http");
const server = http
  .createServer((req, res) => {
		const data = 123 // 取数据
    res.end(`cb(${data})`);  // 数据作为参数
  })
  .listen("3333");
// 浏览器
<script>
		// 定义好回调函数,后端数据会当做参数传入
    function cb(data) {
      console.log(`data是${data}`);
    }
    const scr = document.createElement("script");
    scr.setAttribute("src", "http://localhost:3333");
    document.documentElement.appendChild(scr);
</script>

jsonp解决跨域的本质是利用了script 等html标签所发送的http请求不受到同源策略的限制这一特性,script请求的内容会作为JavaScript代码直接执行,因此只需要和后端约定好回调函数名、要传回的数据等就可以使用jsonp完成跨域。

jsonp的优点是兼容性好,缺点也很明显:使用起来繁琐;只支持get方法;不安全,容易被跨站脚本攻击等。

跨源资源共享(CORS)

CORS是生产环境下常用的一种解决跨域的方法,它使得XMLHttpRequestFetch API 可以跳过同源策略的限制。

CORS分为两种情况:简单请求和非简单请求。

简单请求

满足以下所有条件的才是简单请求,否则是非简单请求。

  • 使用下列方法之一:

    • [GET](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/GET)

    • [HEAD](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/HEAD)

    • [POST](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/POST)

  • 除了被用户代理自动设置的首部字段(例如 [Connection](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Connection)[User-Agent](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/User-Agent))和在 Fetch 规范中定义为 禁用首部名称 的其他首部,允许人为设置的字段为 Fetch 规范定义的 对 CORS 安全的首部字段集合。该集合为:

    • [Accept](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Accept)

    • [Accept-Language](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Accept-Language)

    • [Content-Language](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Language)

    • [Content-Type](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Type)(需要注意额外的限制)

  • [Content-Type](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Type) 的值仅限于下列三者之一:

    • text/plain

    • multipart/form-data

    • application/x-www-form-urlencoded

  • 请求中的任意 [XMLHttpRequest](https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest) 对象均没有注册任何事件监听器;[XMLHttpRequest](https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest) 对象可以使用 [XMLHttpRequest.upload](https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/upload) 属性访问。

  • 请求中没有使用 [ReadableStream](https://developer.mozilla.org/zh-CN/docs/Web/API/ReadableStream) 对象。

简单请求仅使用Origin[Access-Control-Allow-Origin](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) 字段进行控制,浏览器发送请求时会在请求头加上origin字段,该字段的值是当前网站的源,得到服务器响应后检查相应头的[Access-Control-Allow-Origin](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) 字段。

// 服务器
const http = require("http");
const server = http
  .createServer((req, res) => {
    res.setHeader("Access-Control-Allow-Origin", "*");
    res.end("123");
  })
  .listen("3333", () => {
    console.log("port 3333");
  });
// 客户端
<script>
    const xhr = new XMLHttpRequest();
    const url = "http://localhost:3333/";
    xhr.open("GET", url);
    xhr.send();
</script>

非简单请求

简单请求的判定条件比较苛刻,通常使用XMLHttpRequestFetch 发送的http请求是非简单请求。

与简单请求不同,非简单请求会先发送一个options方法的预检请求到服务器,以获知服务器是否允许该实际请求。"预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

预检请求会携带三个请求头字段OriginAccess-Control-Request-HeadersAccess-Control-Request-Method: GET 。响应头也有对应的三个字段Access-Control-Allow-HeadersAccess-Control-Allow-MethodsAccess-Control-Allow-Origin 。只有三者都对应,才能实现跨域。

// 客户端
<script>
    const xhr = new XMLHttpRequest();
    const url = "http://localhost:3333/";
    xhr.open("GET", url);
    xhr.setRequestHeader("Authorization", "123");
    xhr.setRequestHeader("a", "111");
    xhr.send();
</script>
// 服务器
const http = require("http");
const server = http
  .createServer((req, res) => {
    res.setHeader("Access-Control-Allow-Origin", "*");
    res.setHeader("Access-Control-Allow-Methods", " POST, GET, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "a,authorization");
    res.end("123");
  })
  .listen("3333", () => {
    console.log("port 3333");
  });

除了上面6个首部字段外,响应头还可以设置Access-Control-Max-Age ,例如Access-Control-Max-Age: 86400 就表示在86400s内无需再发送预检请求。

身份凭证

默认情况下跨域的fetch和xhr不会携带身份凭证(通常是cookie),如果要携带身份凭证则需要进行相应的设置,例如xhr要设置xhr.withCredentials=true ,并且响应头还得满足:

  1. Access-Control-Allow-Credentials: true

  2. Access-Control-Allow-Origin 的值不能是*

如果是响应携带身份凭证,则Access-Control-Allow-HeadersAccess-Control-Allow-MethodsAccess-Control-Allow-Origin 的值都不能是’*’。

// 客户端
<script>
    const xhr = new XMLHttpRequest();
    const url = "http://localhost:3333/";
    xhr.open("GET", url);
    xhr.setRequestHeader("Authorization", "123");
    xhr.withCredentials = true;
    xhr.send();
</script>
// 服务器
const http = require("http");
const server = http
  .createServer((req, res) => {
    res.setHeader("Access-Control-Allow-Origin", "http://127.0.0.1:5500");
    res.setHeader("Access-Control-Allow-Methods", " POST, GET, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "a,authorization");
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.end("123");
  })
  .listen("3333", () => {
    console.log("port 3333");
  });

总结

请求头
响应头
作用

origin

access-control-allow-origin

允许的源

access-control-request-headers

access-control-allow-headers

允许的请求头

access-control-request-method

access-control-allow-method

允许的方法

access-control-allow-credentials

是否可携带凭证

access-control-max-age

有效时间,在有效时间内不会再发送预检请求

document.domain

当同一域名下的两个子域名需要实现跨域时,可以将document.domain设置成父域名,然后就可以实现cookie共享。

注意: 目前几乎所有主流浏览器都支持此特性,但是未来可能会被废弃。

PostMessage

window.postMessage() 是一种可以安全地实现跨源通信的方法。

使用PostMessage首先需要获取到另一个窗口的引用,然后调用otherWindow.postMessage(message, targetOrigin, [transfer]);

otherWindow

其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行window.open返回的窗口对象、或者是命名过或数值索引的window.frames

message

将要发送到其他 window 的数据。它将会被结构化克隆算法序列化。这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化。[1]

targetOrigin

通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个 URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配 targetOrigin 提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。这个机制用来控制消息可以发送到哪些窗口;防止数据被恶意的第三方截获。

transfer 可选

是一串和 message 同时传递的 [Transferable](https://developer.mozilla.org/zh-CN/docs/Web/API/Transferable) 对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

而对于接收方,则需要通过window.addEventListener('message',function(res){}) 来接收消息。其中res参数有三个很重要的属性。

origin

调用 postMessage时消息发送方窗口的 origin,这个字符串由 协议、“://“、域名、“ : 端口号”拼接而成。接收方跨域通过该属性避免接收不明网站发送的信息。

data

postMessage发送的消息。

source

对发送消息的窗口对象的引用; 您可以使用此来在具有不同 origin 的两个窗口之间建立双向通信。

示例

// http://127.0.0.1:5500/a1.html
<script>
    const w1 = window.open("http://127.0.0.1:5500/a2.html");
    setTimeout(() => {
			// 避免a2页面还没加载完成就发送消息
      w1.postMessage("123", "http://127.0.0.1:5500/a2.html");
    }, 1000);
</script>

// http://127.0.0.1:5500/a2.html
<script>
    window.addEventListener(
      "message",
      function (res) {
        // 确认发送方身份
        if (res.origin === "http://127.0.0.1:5500") {
        }
      },
      false
    );
</script>

注意 如果不需要接收message消息,则应该直接避免监听message消息,如果需要接收message消息,则应当始终使用 origin 和 source 属性验证发件人的身份,防止跨站脚本攻击。