浏览器同源策略

跨域(CORS)是由浏览器的同源策略(Same-Origin Policy, SOP)引起的,它是浏览器端的安全机制,与前后端服务无关。

同源(Same-Origin) = 协议 + 域名 + 端口必须相同,否则会触发跨域。

请求来源 目标地址 是否同源
http://example.com:8080 http://example.com:8080 ✅ 同源
http://example.com:8080 http://example.com:9090 ❌ 跨域(端口不同)
http://example.com https://example.com ❌ 跨域(协议不同)
http://example.com http://api.example.com ❌ 跨域(域名不同)

跨源资源共享CORS

跨源资源共享(CORS,或通俗地译为跨域资源共享)是一种基于 HTTP 头的机制(用于解决浏览器同源策略问题),该机制通过允许服务器标示除了它自己以外的其他(域、协议或端口),使得浏览器允许这些源访问加载自己的资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的“预检”请求。在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头。

CORS 机制允许 Web 应用服务器进行跨源访问控制,从而使跨源数据传输得以安全进行。

结合前后端理解,CORS(Cross-Origin Resource Sharing,跨域资源共享)是服务器(后端)允许浏览器上的非同源网站(前端)访问加载自己的资源。

简单请求

简单请求不会触发 CORS 预检请求,可以直接访问非同源资源。如下图所示:

以下是浏览器发送给服务器的请求报文,满足简单请求的条件:

1
2
3
4
5
6
7
8
GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example

请求标头字段 Origin 表明该请求来源于 http://foo.example

服务器响应报文:

1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[…XML Data…]

服务端返回的 Access-Control-Allow-Origin 标头的 Access-Control-Allow-Origin: * 值表明,该资源可以被任意外源访问。

预检请求

简单请求不同,“需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。”预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。如下图所示:

如下是一个需要执行预检请求的 HTTP 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const fetchPromise = fetch("https://bar.other/doc", {
method: "POST",
mode: "cors",
headers: {
"Content-Type": "text/xml",
"X-PINGOTHER": "pingpong",
},
body: "<person><name>Arun</name></person>",
});

fetchPromise.then((response) => {
console.log(response.status);
});

上面请求的 Content-Typeapplication/xml,且包含了一个非标准的 HTTP X-PINGOTHER 请求标头,所以该请求需要首先发起“预检请求”。

下面是服务端和客户端完整的信息交互。首次交互是预检请求/响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
OPTIONS /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

客户端的请求标头字段:

Access-Control-Request-Method 告知服务器,实际请求将使用 POST 方法。

Access-Control-Request-Headers 告知服务器,实际请求将携带两个自定义请求标头字段:X-PINGOTHERContent-Type

服务端的响应标头字段:

Access-Control-Allow-Origin: https://foo.example 限制请求的源域。

Access-Control-Allow-Methods 表明服务器允许客户端使用 POSTGET 方法发起请求。

Access-Control-Allow-Headers 表明服务器允许请求中携带字段 X-PINGOTHERContent-Type

Access-Control-Max-Age 给定了该预检请求可供缓存的时间长短,单位为秒,默认值是 5 秒。在有效时间内,浏览器无须为同一请求再次发起预检请求。以上例子中,该响应的有效时间为 86400 秒,也就是 24 小时。请注意,浏览器自身维护了一个最大有效时间,如果该标头字段的值超过了最大有效时间,将不会生效。

预检请求完成之后,发送实际请求:

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
27
28
29
30
POST /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF-8
Referer: https://foo.example/examples/preflightInvocation.html
Content-Length: 55
Origin: https://foo.example
Pragma: no-cache
Cache-Control: no-cache

<person><name>Arun</name></person>

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain

[Some XML payload]

解决浏览器同源策略的方法

前端代理、Nginx 代理和后端配置 CORS 都是解决浏览器的同源策略(Cross-Origin Resource Sharing,CORS)问题的常用方法。

后端开启CORS

后端开启 CORS 允许跨源资源请求,返回 Access-Control-Allow-Origin: * 等允许跨域的响应报文。

nginx反向代理

Nginx 通过代理服务器转发请求,让前端请求 Nginx,而不是直接请求后端,从而绕过浏览器的同源策略。

原理:

  1. 前端请求 Nginx 服务器(http://frontend.com/api)。
  2. Nginx 反向代理,将请求转发到后端服务(http://backend.com)。
  3. 后端返回数据给 Nginx,Nginx 再把数据返回给前端。(注意 Nginx 缓冲和缓存引发的问题)
  4. 整个过程中,前端请求的始终是 frontend.com,不会被浏览器判定为跨域。

Nginx 配置示例如下:

1
2
3
4
5
6
7
8
9
10
11
server {
listen 80;
server_name frontend.com;

location /api/ {
proxy_pass http://backend.com:9999/;
proxy_set_header Host $host; # 将原始请求的 Host 头信息传递给后端服务器。
proxy_set_header X-Real-IP $remote_addr; # 让后端服务器知道客户端的真实 IP 地址,而不是 Nginx 代理服务器的 IP。
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 记录完整的代理链路,将客户端 IP 和所有经过的代理服务器 IP 都传递给后端。
}
}

前端代理(开发环境)

Vue 3 的 Vite 或 Vue CLI 开发服务器的代理配置如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
devServer: {
port: 8080,
proxy: {
'/api': {
target: process.env.VUE_APP_BASE_API, // 代理到后端地址
ws: true, // 允许 WebSocket 代理
changeOrigin: true, // 修改 Origin 头为目标服务器的 Origin,防止后端服务器因收到不同源的请求而拒绝请求
rewrite: (path) => path.replace(/^\/api/, '') // 移除 `/api` 前缀
}
}
}

上述两种前端代理和Nginx 反向代理配置,其核心目的都是让前端请求看起来是从同源发出的,从而避免浏览器的跨域限制,而不是以 CORS 机制解决浏览器的同源策略。

参考

跨源资源共享官方文档:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CORS