BTOA

什么是跨域问题

2024-07-13 18:22 · 跨域

如果你是一个前端开发者,你一定有见过下面这个报错:

request has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

在客户端使用 XMLHttpRequest 或者 fetch API 请求服务端接口时,如果服务端没有正确设置响应头,大概率会出现该报错。这是跨域问题(CORS),本篇文章将简单介绍该问题。

同源策略

在介绍跨域问题之前,我们先要知道什么是同源策略。同源策略是游览器(注意这里的浏览器,圈起来,下文要考)的安全策略,浏览器根据请求协议、域名(IP)和端口号来判断“源”,不同“源”之间通过 XMLHttpRequest 或者 fetch API 请求的数据不能毫无限制地使用。以下例子均不是同源:

  • http://google.comhttps://google.com:协议不同
  • https://google.comhttps://mail.google.com:域名不同
  • http://localhost:8080http://localhost:80:端口不同

其中第二个例子需要特别注意,mail.google.comgoogle.com 的子域名,它们可以共用 cookie,但并不同源。

如果单从网址来判断是否同源,有一个很简单的方法,只需要对比页内路径(path)之前的内容是否相同即可,例如 http://localhost:3000/api/userhttp://localhost:3000/page/about 就是同源的。

理解了同源的定义后,我们或许有个疑问,为什么不能跨源请求数据?或者更准确一点,为什么不能跨源使用 XMLHttpRequest 或者 fetch API 请求数据?因为我们可以很容易地使用 <script src='xxx' /><img src='xxx' /> 引入其他网站的资源,这显然不会导致跨域问题。我们可以先设想一下,假如我们能够使用 XMLHttpRequest 或者 fetch API 拿到其他网站的数据,那就意味着我们可以自己搭建一个网站,在网站中写一段 JS 代码去请求 https://google.com 的数据。也许拿到谷歌首页的数据并没有什么用处,但按照这个思路继续联想,我也能拿到 https://mail.google.com/mail/u/0 的数据。如果我还能发送谷歌的 cookie,就意味着我能拿到所有用户的邮件数据。当然,现在许多网站的安全措施很完善,一般来说,获取用户比较隐私的数据需要鉴权处理,但别忘了,有些公司内部的网站在外网是无法访问的,所以并不需要做太多的安全防护。如果没有同源策略的限制,我可以写一个网站,在网站中写一段 JS 代码遍历所有内网的 IP 和端口请求数据,并把数据发回自己的网站存下来,一旦这个公司的员工点开了我写的网站,公司内网的数据就被盗取了。

为什么黑客网站可以拿到内网数据,原因是 XMLHttpRequest 请求是客户端请求,它的发起 IP 是客户端 IP,所以公司员工打开网站后发送的请求其实是内网 IP 发送的请求。

看完上述例子,有疑问的小伙伴应该明白为什么浏览器要引入同源策略了吧,说穿了就三个字:安全性。那为什么浏览器对图片、CSS 和 JavaScript 等资源没有做同源限制呢?因为它们请求的内容有限,并没有包含其他网站的敏感信息。

一些错误认知

在这一部分,我打算列举两个和跨域相关并且误导性较强的案例,首先来看第一个案例:

我在某个网站中存放了一个 json 文件,它的链接是 https://xxxx.xxx.com/files/xxx.json,现在我在浏览器地址栏直接输入链接,发现可以访问这个 json 文件,所以我认为使用 fetch API 也能拿到这个文件。

在上文中提到过,跨域限制的是在某个网站内使用 XMLHttpRequest 或 fetch API 请求其他网站的数据,我们在浏览器地址栏输入链接并打开,可以理解成打开网站的操作,就像你打开 Google 网站一样,它永远不会被限制。要看一个请求是否产生跨域问题,必须使用浏览器的 XMLHttpRequest 或 fetch API 请求数据,如果响应头中 Access-Control-Allow-Origin 的值是 *,则不会产生跨域问题,如果是个具体的域名,那就要看这个域名和发送请求的网站的域名是否相同,如果相同也不会产生跨域问题,如果不同或没有这个值,就一定会产生跨域问题。

既然这里提到了浏览器,就顺便讲一下运行环境吧,我们常说使用 JS 是前端干的活,但实际上 JS 有多个运行环境,它既可以在客户端(浏览器)上跑,也可以在 Node 环境下运行。JS 中有一些我们常用的 API 是区分运行环境的,例如 localStoragedocument 只能在浏览器中使用,而 fsprocess 是 Node 环境提供的。还有一些是通用的,例如 Object.entries(),它是 ECMAScript 提供的 API,我们可以在 MDN 网站查看这些内容。

所以有些刚入门 JS 就使用 Next.js 这类框架的开发者,在 ServerComponent 里使用 Buffer.from() API 不会报错,就以为在 ClientComponent 中也可以用,最后不出意外,报错了。如果我们明白 ServerComponent 和 ClientComponent 运行的环境不一样,就不会犯这种错误。

接下来我们看第二个案例:

在某个后端程序中,有一个删除文章的接口,这个接口接受 Content-Type 为 text/plain 的 POST 请求,并且该接口没有做鉴权处理,现在后端程序部署在 localhost:3001 上。同时,我又部署了一个网站在 localhost:3000,并在网站中使用 XMLHttpRequest 请求了删除文章的接口,发现产生了跨域问题,所以我认为文章没有被删掉。

这个案例想解释的是跨域究竟在哪一步被阻挡了,先上答案,跨域阻挡的是响应(response)而不是请求(request),因为浏览器没法确认发送给服务端的请求是否合法,这个需要服务端进行判定。实际上,第二个案例中的文章已经删除了,只是删除后服务端返回给浏览器客户端的响应被阻挡了。在这个例子中,我使用的 Content-Type 是 text/plain,请求方法是 POST,这是一个简单请求,所以不需要预检。关于什么是简单请求,以及预检的相关问题,可以参考 MDN 跨域文章中的相关说明,这里只举一个简单的例子:

const xhr = new XMLHttpRequest();
xhr.open("POST", "https://bar.other/resources/post-here/");
xhr.setRequestHeader("X-PINGOTHER", "pingpong");
xhr.setRequestHeader("Content-Type", "application/xml");
xhr.onreadystatechange = handler;
xhr.send("<person><name>Arun</name></person>");

这个请求包含了一个非标准的 HTTP X-PINGOTHER 请求标头,并且使用了额外的 Content-Type 值 application/xml,它与服务端的整个交互过程如下:

如何解决跨域问题

在上文中我有提到,浏览器 <script /> 标签并不受跨域问题影响,所以我们可以用它来获取数据,具体操作过程如下:

先写一个后端程序用来发送响应,它会根据传入的 postId 返回对应文章的标题:

var express = require("express");
var app = express();

const posts = {
  1: { title: "post1" },
  2: { title: "post2" },
  3: { title: "post3" },
};

app.get("/posts/:postId", function (req, res) {
  const postId = req.params.postId;
  res.end(`setPost(${JSON.stringify(posts[postId])})`);
});

app.listen(3000, function () {
  console.log("Example app listening on port 3000!");
});

前端页面可以这样写:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <script>
      function setPost(data) {
        console.log(data);
      }
      function getPost(postId) {
        const script = document.createElement("script");
        script.src = "http://localhost:3000/posts/" + postId;
        document.body.appendChild(script);
      }
    </script>
  </head>
  <body>
    <button onclick="getPost(1)">post1</button>
    <button onclick="getPost(2)">post2</button>
    <button onclick="getPost(3)">post3</button>
  </body>
</html>

当点击 post1 按钮时,浏览器会插入一个 srchttp://localhost:3000/posts/1 的 script 标签,它的脚本内容是 setPost({ title: "post1" }),刚好执行了全局的 setPost 方法,所以控制台打印出了 { title: "post1" }。这种方法叫做 JSONP,在很多古老的项目中被使用。它的弊端是必须前后端配合才能实现,因为服务端的响应并不是一段数据,而是一段 JS 代码。由于必须使用 script 标签,这种方法只支持 GET 请求,存在较大的局限性。

第二种方法,也是最根本的解决方法:让后端同事设置正确的响应头。跨域问题往往不是前端个人的问题,所以遇到这种问题时,最好的解决方法就是请求后端帮助。本文并没有很详细介绍与跨域相关的请求头和响应头,包括是否携带 cookie,因为这些内容在 MDN 文档里面都写了,我再写不过是复制粘贴,没太大意义。

最后一种方法:使用代理(proxy)。上文中提到,跨域问题是浏览器安全限制导致的,所以当我们摆脱浏览器后,就能很轻松绕开跨域问题,我们可以自己写一个服务端去获取数据,然后丢回浏览器:

写这一篇文章的起因是我最近在开发中遇到了一个 CDN 的跨域问题,只是一个简单的缓存问题,却让我排查了好久。刚好借这个机会,就写了一篇关于跨域问题的文章,文章的内容也许不是很全面,如果有什么遗漏,还请见谅。