185 lines
6.9 KiB
Markdown
185 lines
6.9 KiB
Markdown
|
|
---
|
|||
|
|
tags: [后端, Go, Gin, CORS, 跨域, 预检]
|
|||
|
|
create time: 2026-04-27 13:00
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# CORS OPTIONS 预检请求详解
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
当浏览器发起**跨域非简单请求**时,会在实际请求之前先发送一个 `OPTIONS` 预检请求,询问服务端是否允许该操作。本文完整解析预检触发条件、两轮对话流程及底层机制。
|
|||
|
|
|
|||
|
|
## 正文
|
|||
|
|
|
|||
|
|
### 什么时候触发预检?
|
|||
|
|
|
|||
|
|
并非所有跨域请求都经过预检。以下四种情况**之一满足即触发**:
|
|||
|
|
|
|||
|
|
| 触发条件 | 说明 |
|
|||
|
|
|---------|------|
|
|||
|
|
| 方法不是简单方法 | 使用了 `PUT`、`DELETE`、`PATCH`、`HEAD` 等 |
|
|||
|
|
| 请求头包含自定义 Header | 如 `X-Token`、`Authorization`(`Accept`/`Content-Type`/`Language` 除外) |
|
|||
|
|
| `Content-Type` 是非简单类型 | 如 `application/json`;`text/plain`、`multipart/form-data`、`application/x-www-form-urlencoded` 是简单类型 |
|
|||
|
|
| 跨域且携带凭证 | `withCredentials: true` 或 `credentials: "include"` |
|
|||
|
|
|
|||
|
|
> **思考**:为什么同源请求不需要预检?因为同源策略本身就已经限制了跨站操作的安全性,不需要额外协商。
|
|||
|
|
|
|||
|
|
### 预检的两轮对话
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant B as 浏览器
|
|||
|
|
participant S as 服务端
|
|||
|
|
|
|||
|
|
B->>S: OPTIONS /api/resource
|
|||
|
|
Note over B,S: Origin<br/>Access-Control-Request-Method<br/>Access-Control-Request-Headers
|
|||
|
|
S-->>B: 204 No Content
|
|||
|
|
Note over B,S: Access-Control-Allow-Origin<br/>Access-Control-Allow-Methods<br/>Access-Control-Allow-Headers<br/>Access-Control-Max-Age
|
|||
|
|
B->>S: PUT /api/resource
|
|||
|
|
Note over B,S: Origin<br/>X-Token: xxx
|
|||
|
|
S-->>B: 200 OK + CORS headers
|
|||
|
|
|
|||
|
|
rect rgba(200, 255, 200, 0.3)
|
|||
|
|
Note over B,B: ① 预检请求
|
|||
|
|
end
|
|||
|
|
rect rgba(255, 255, 200, 0.3)
|
|||
|
|
Note over S,S: ② 预检响应
|
|||
|
|
end
|
|||
|
|
rect rgba(200, 200, 255, 0.3)
|
|||
|
|
Note over B,B: ③ 实际请求
|
|||
|
|
end
|
|||
|
|
rect rgba(255, 200, 200, 0.3)
|
|||
|
|
Note over S,S: ④ 实际响应
|
|||
|
|
end
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### ① 预检请求 — 浏览器发问
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
OPTIONS /api/resource HTTP/1.1
|
|||
|
|
Host: api.example.com
|
|||
|
|
Origin: https://app.example.com
|
|||
|
|
Access-Control-Request-Method: PUT
|
|||
|
|
Access-Control-Request-Headers: X-Token, Content-Type
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
两个关键自定义 Header 由浏览器**自动添加**,开发者无法操控:
|
|||
|
|
- `Access-Control-Request-Method` — 打算用的 HTTP 方法
|
|||
|
|
- `Access-Control-Request-Headers` — 打算带的自定义 Header 列表
|
|||
|
|
|
|||
|
|
#### ② 预检响应 — 服务端答复
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
HTTP/1.1 204 No Content
|
|||
|
|
Access-Control-Allow-Origin: https://app.example.com
|
|||
|
|
Access-Control-Allow-Methods: PUT, GET, POST, DELETE
|
|||
|
|
Access-Control-Allow-Headers: X-Token, Content-Type
|
|||
|
|
Access-Control-Max-Age: 86400
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
注意用 `204 No Content`——预检**没有响应体**,因为还没确认要处理实际请求。
|
|||
|
|
|
|||
|
|
#### ③ 实际请求 — 预检通过后才发
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
PUT /api/resource HTTP/1.1
|
|||
|
|
Host: api.example.com
|
|||
|
|
Origin: https://app.example.com
|
|||
|
|
X-Token: eyJhbG...
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
如果预检失败(收到非 2xx 或缺少必要 header),浏览器直接**静默拒绝**,不会发送实际请求。
|
|||
|
|
|
|||
|
|
#### ④ 实际响应 — 浏览器二次校验
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
HTTP/1.1 200 OK
|
|||
|
|
Access-Control-Allow-Origin: https://app.example.com
|
|||
|
|
Access-Control-Allow-Credentials: true
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
浏览器的实际请求**不经过预检**,但浏览器仍会检查响应中的 CORS header 是否匹配当前页面来源。
|
|||
|
|
|
|||
|
|
### 关键 Header 速查
|
|||
|
|
|
|||
|
|
| 请求/Header | 方向 | 作用 |
|
|||
|
|
|-------------|------|------|
|
|||
|
|
| `Origin` | → 和 ↩ | 当前页面的源(协议+域名+端口),**不可自定义** |
|
|||
|
|
| `Access-Control-Allow-Origin` | ↩ | 允许的源;与 `credentials: true` 搭配时不能为 `*` |
|
|||
|
|
| `Access-Control-Allow-Methods` | ↩ | 预检通过的 HTTP 方法列表(逗号分隔) |
|
|||
|
|
| `Access-Control-Allow-Headers` | ↩ | 预检通过的自定义 Header 列表 |
|
|||
|
|
| `Access-Control-Max-Age` | ↩ | 预检结果缓存时长(秒),期间不再发 OPTIONS |
|
|||
|
|
| `Access-Control-Allow-Credentials` | ↩ | 是否允许带 Cookie/认证信息 |
|
|||
|
|
| `Access-Control-Expose-Headers` | ↩ | 允许 JS `getResponseHeader()` 读取的响应头白名单 |
|
|||
|
|
|
|||
|
|
> **常见坑**:`Allow-Credentials: true` 时 `Allow-Origin` 必须写死具体域名,不能用 `*`。这是浏览器强制校验的安全策略。
|
|||
|
|
|
|||
|
|
### Max-Age 缓存机制
|
|||
|
|
|
|||
|
|
浏览器会对成功的预检结果做缓存:
|
|||
|
|
|
|||
|
|
- `Max-Age: 86400` = 24 小时内再次访问同一路径,浏览器**跳过预检**直接发实际请求
|
|||
|
|
- 不同路径、不同方法、不同 Origin 视为不同的预检条目,各自独立缓存
|
|||
|
|
- 如果服务端返回 `Max-Age: 0`,浏览器每次都会预检
|
|||
|
|
|
|||
|
|
这也是为什么优化跨域性能时,把 `Max-Age` 设置大一些比取消中间件顺序更有意义。
|
|||
|
|
|
|||
|
|
### Gin 中的实现要点
|
|||
|
|
|
|||
|
|
对应的中间件逻辑非常简单——**只要区分是否是 OPTIONS 请求**:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func CORSMiddleware() gin.HandlerFunc {
|
|||
|
|
return func(c *gin.Context) {
|
|||
|
|
if c.Request.Method == http.MethodOptions {
|
|||
|
|
// 预检请求:只设 header + 拦截
|
|||
|
|
c.Header("Access-Control-Allow-Origin", "*")
|
|||
|
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|||
|
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Token")
|
|||
|
|
c.AbortWithStatus(http.StatusNoContent) // 204, 无 Body
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
// 实际请求:正常走链,但响应头里也要注入 CORS(供浏览器校验)
|
|||
|
|
c.Header("Access-Control-Allow-Origin", "*")
|
|||
|
|
c.Next()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
核心就两件事:
|
|||
|
|
- **OPTIONS → 只设 header,`c.Abort()` 终止链**,不进任何 handler
|
|||
|
|
- **其他方法 → 正常请求,但 `c.Header()` 注入 header 到最终响应**(浏览器的实际请求需要这些 header 来确认服务端确实允许)
|
|||
|
|
|
|||
|
|
这就是为什么它适合注册为全局中间件——不在路由匹配前拦截预检的话,Gin 会因为找不到 `OPTIONS` 路由而返回 405/404,浏览器认为预检失败就直接阻塞了后续所有请求。
|
|||
|
|
|
|||
|
|
### 前后端对照
|
|||
|
|
|
|||
|
|
同一接口的完整交互视角(前端发起):
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// 前端代码
|
|||
|
|
fetch("https://api.example.com/resource", {
|
|||
|
|
method: "PUT",
|
|||
|
|
credentials: "include", // ← 触发携带凭证模式
|
|||
|
|
headers: {
|
|||
|
|
"Content-Type": "application/json", // ← 触发预检(非简单 Content-Type)
|
|||
|
|
"X-Token": token, // ← 触发预检(自定义 Header)
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({ name: "updated" }),
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
上述代码在实际发 `PUT` 请求前,浏览器必定先发一轮 `OPTIONS`。如果把其中任意一个触发条件去掉(比如改用 `GET` + `text/plain`),就可以省去预检往返。
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[cors-registration-scope]] — 全局 vs 分组注册 CORS 中间件的取舍
|
|||
|
|
- [[GIN/3-middleware]] — 中间件三级作用域机制
|
|||
|
|
- [[middleware-abort]] — `c.Abort()` 终止机制
|
|||
|
|
|
|||
|
|
|