什么是跨域问题
如果你是一个前端开发者,你一定有见过下面这个报错:
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.com
和https://google.com
:协议不同https://google.com
和https://mail.google.com
:域名不同http://localhost:8080
和http://localhost:80
:端口不同
其中第二个例子需要特别注意,mail.google.com
是 google.com
的子域名,它们可以共用 cookie,但并不同源。
如果单从网址来判断是否同源,有一个很简单的方法,只需要对比页内路径(path)之前的内容是否相同即可,例如 http://localhost:3000/api/user
和 http://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 是区分运行环境的,例如 localStorage
和 document
只能在浏览器中使用,而 fs
和 process
是 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
按钮时,浏览器会插入一个 src
为 http://localhost:3000/posts/1
的 script 标签,它的脚本内容是 setPost({ title: "post1" })
,刚好执行了全局的 setPost
方法,所以控制台打印出了 { title: "post1" }
。这种方法叫做 JSONP,在很多古老的项目中被使用。它的弊端是必须前后端配合才能实现,因为服务端的响应并不是一段数据,而是一段 JS 代码。由于必须使用 script 标签,这种方法只支持 GET 请求,存在较大的局限性。
第二种方法,也是最根本的解决方法:让后端同事设置正确的响应头。跨域问题往往不是前端个人的问题,所以遇到这种问题时,最好的解决方法就是请求后端帮助。本文并没有很详细介绍与跨域相关的请求头和响应头,包括是否携带 cookie,因为这些内容在 MDN 文档里面都写了,我再写不过是复制粘贴,没太大意义。
最后一种方法:使用代理(proxy)。上文中提到,跨域问题是浏览器安全限制导致的,所以当我们摆脱浏览器后,就能很轻松绕开跨域问题,我们可以自己写一个服务端去获取数据,然后丢回浏览器:
写这一篇文章的起因是我最近在开发中遇到了一个 CDN 的跨域问题,只是一个简单的缓存问题,却让我排查了好久。刚好借这个机会,就写了一篇关于跨域问题的文章,文章的内容也许不是很全面,如果有什么遗漏,还请见谅。