跨域资源共享(CORS)

首先的首先,跨域资源共享(Cross-Origin Resource Sharing,简称CORS)是为浏览器服务的,web api可以无视。

它赋予了浏览器阻止前端JavaScript代码访问跨域请求的能力。

什么是跨域请求

跨域请求是指在浏览器中,通过前端JavaScript代码发送的HTTP请求,目标资源位于当前网页所在的域之外。
换句话说,当一个网页尝试从不同的源(域、协议或端口)请求数据或资源时,就会发生跨域请求。

举个实际的例子:微信防盗图。

我们随便找一个微信公众号文章,从里面找一张图片,比如上海发布的这张图

直接打开
直接在浏览器中点击打开,显示正常。

本地文件
但如果我们把这个链接加到一个网页中,比如:

1
2
3
4
5
6
7
8
9
10
11
<html>
<head>
<title>测试微信防盗图</title>
<meta charset="UTF-8" />
</head>
<body>
<img
src="https://mmbiz.qpic.cn/mmbiz_gif/qdWB7wH8tToVOJUFAamQplZiaeSUp5JOK4aicbicMBfRCmnKxwwQicuCEyP48QZadBPicCRhgSiaqFu2b3qIMcaawlYA/640?wx_fmt=gif&wxfrom=5&wx_lazy=1"
/>
</body>
</html>

将它保存为test.html,然后在浏览器中打开,会发现图片显示仍然正常。这是从文件系统中打开的,微信允许这种跨域。

跨域请求
我们在test.html同目录运行一个网络服务,比如:

1
python -m http.server 8000

然后使用这个网址http://localhost:8000/test.html再次打开这个网页,会发现图片显示不正常了。

这是因为微信判断了请求头中的Referer,发现它们不是微信的域名,所以返回了错误提示图片。

在这个例子里,为了更简单,我们使用了图片而不是JavaScript代码。在默认的情形下,图片的访问并没有使用CORS。但足够我们对CROS有初步的认识。

一个简单的跨域请求例子

我们修改一下test.html,使用fetch来发送一个跨域请求,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<html>
<head>
<title>测试跨域请求</title>
<meta charset="UTF-8" />
</head>
<body>
<div id="log"></div>
<script>
const log = (msg) => {
document.getElementById("log").innerHTML += `<p>${msg}</p>`;
};
const f = async () => {
const request = new Request("http://localhost:3000/api/hello", {
method: "post",
});
const result = await fetch(request);
const text = await result.text();
log(text);
};
f().catch((err) => {
log(err);
});
</script>
</body>
</html>

然后我们需要运行一个网络服务来提供web api服务,此时python的简单http.server就不够用了,我们换deno

1
2
3
4
5
6
7
8
9
10
11
12
13
// @deno-types="npm:@types/[email protected]"
import express from "npm:[email protected]"

const app = express()
const port = 3000

app.post('/api/hello', (req, res) => {
res.send('Welcome to the Dinosaur API!')
})

app.listen(port, () => {
console.log(`Listening on http://localhost:${port}`)
})

我们先运行起来这个服务:

1
deno run -A ./server.ts

使用Invoke-WebRequest来测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PS > Invoke-WebRequest -Method 'POST' -Uri 'http://localhost:5000/api/hello'
StatusCode : 200
StatusDescription : OK
Content : Welcome to the Dinosaur API!
RawContent : HTTP/1.1 200 OK
vary: Accept-Encoding
Content-Length: 28
Content-Type: text/html; charset=utf-8
Date: Mon, 18 Sep 2023 02:45:27 GMT
ETag: W/"1c-GX2ZfGSbrWakhyxxP2SDs+BrRUw"
X-Powered-By: Express...
Forms : {}
Headers : {[vary, Accept-Encoding], [Content-Length, 28], [Content-Type, text/html; charset
=utf-8], [Date, Mon, 18 Sep 2023 02:45:27 GMT]...}
Images : {}
InputFields : {}
Links : {}
ParsedHtml : mshtml.HTMLDocumentClass
RawContentLength : 28

接着我们仍然在test.html同目录使用python -m http.server 8000来运行一个网络服务,然后在浏览器中打开http://localhost:8000/test.html

跨域错误

会发现控制台中有错误:

test.html:1 Access to fetch at ‘http://localhost:3000/api/hello‘ from origin ‘http://localhost:8000‘ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.

意思是返回头没有指定包含Access-Control-Allow-Origin,所以浏览器阻止了前端JavaScript代码访问跨域请求的返回内容。

但这个请求本身是成功的,我们可以在浏览器调试工具的网络窗口中查看到返回头:

1
2
3
4
5
6
7
HTTP/1.1 200 OK
content-length: 28
content-type: text/html; charset=utf-8
etag: W/"1c-GX2ZfGSbrWakhyxxP2SDs+BrRUw"
x-powered-by: Express
vary: Accept-Encoding
date: Mon, 18 Sep 2023 03:13:36 GMT

只是浏览器阻止了fetch的返回内容并报错。

no-cors request mode

控制台的错误提示有这么一句:

If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.

如果我们需要的是不透明的返回,可以设置request.modeno-cors来禁用CORS。

什么叫不透明的返回呢?意思是只关心调用是否成功,不关心返回内容。

我们试着修改一下fetch的Request:

1
2
3
4
const request = new Request("http://localhost:3000/api/hello", {
method: "post",
mode: "no-cors",
});

会发现控制台中的错误消失了,但返回内容会为空。所以在绝大多数情况下,no-cors并不是我们需要的。

在使用Request()构建函数时,mode的默认值cors
而在使用嵌入资源时,比如<img><iframe>这些标签时,除非设置了crossorigin属性,否则默认值为no-cors

simple request和preflight request

在上面的例子中,我们发出的跨域请求属于simple request,不会触发preflight request

之所以不对所有跨域请求都使用preflight机制,是为了支持表单<form>。简单的表单提交是允许跨域的。

如果我们修改一下headers中的Content-Type,就会出现preflight请求了:

1
2
3
4
5
6
const request = new Request("http://localhost:3000/api/hello", {
method: "post",
headers: {
"Content-Type": "text/xml",
},
});

解决跨域错误

当发生跨域请求时,不管是simple request还是preflight request,浏览器都会在返回头中检查Access-Control-Allow-Origin是否包含当前Origin

浏览器会在请求头中添加Origin属性,比如在打开一个http://localhost:8080上的网页,js脚本使用了fetch访问http://localhost:5000/api,此次fetch的请求头中,Origin会是http://localhost:8080

而默认的返回头不包含Access-Control-Allow-Origin,所以检查不通过。

解决方法也很简单,在服务端返回头中添加Access-Control-Allow-Origin即可。

cors中间件

几乎所有网络服务框架都提供了cors中间件,比如express的cors,fastify的fastify-cors, koa的@koa/cors

在默认配置下,cors中间件会在返回头中添加Access-Control-Allow-Origin: *,这样就允许了所有的跨域请求。

修改后的服务端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// @deno-types="npm:@types/[email protected]"
import express from "npm:[email protected]"
// @deno-types="npm:@types/cors@2"
import cors from "npm:[email protected]"

const app = express()
const port = 3000

app.use(cors())

app.post('/api/hello', (req, res) => {
res.send('Welcome to the Dinosaur API!')
})

app.listen(port, () => {
console.log(`Listening on http://localhost:${port}`)
})

浏览器扩展

因为阻止跨域请求是由浏览器来做的,所以在无法修改服务器时,也可以使用浏览器扩展来解决。Chrome有很多相关扩展,实现原理是在所有返回头中都加上Access-Control-Allow-Origin: *

有时我们需要在跨域请求中设置cookie,我们修改一下服务端代码:

1
2
3
4
5
app.post('/api/hello', (req, res) => {
res
.cookie('cors-cookie', 'my-ors-cookie')
.send('Welcome to the Dinosaur API!')
})

可以看到返回头中多了一个Set-Cookie

1
Set-Cookie: cors-cookie=my-ors-cookie; Path=/

但是在请求完成后,打开浏览器的调试工具查看Cookies,我们会发现cookie并没有被设置。

credentials

原因是cookie属于credentials。而credentials在跨域时默认是不被允许的。

跨域使用credentials

想要跨域设置cookie,需要满足以下条件:

响应头
如果想跨域设置cookie,需要在响应头中添加这些:

浏览器端
在浏览器端需要设置Request.credentialsinclude

不同的库有不同的设置方法:

扩展阅读

响应头

Set-Cookie

解决方案

最后贴一下完整的代码。

服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// @deno-types="npm:@types/[email protected]"
import express from "npm:[email protected]"
// @deno-types="npm:@types/cors@2"
import cors from "npm:[email protected]"

const app = express()
const port = 3000

app.use(cors({
origin: 'http://localhost:8000',
credentials: true
}))

app.post('/api/hello', (req, res) => {
res
.cookie('cors-cookie', 'my-ors-cookie')
.send('Welcome to the Dinosaur API!')
})

app.listen(port, () => {
console.log(`Listening on http://localhost:${port}`)
})

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<html>
<head>
<title>测试跨域请求</title>
<meta charset="UTF-8" />
</head>
<body>
<div id="log"></div>
<script>
const log = (msg) => {
document.getElementById("log").innerHTML += `<p>${msg}</p>`;
};
const f = async () => {
const request = new Request("http://localhost:3000/api/hello", {
method: "post",
credentials: "include",
});
const result = await fetch(request);
const text = await result.text();
log(text);
};
f().catch((err) => {
log(err);
});
</script>
</body>
</html>