This commit is contained in:
2026-05-24 11:42:38 +08:00
commit 30d312ac35
521 changed files with 146481 additions and 0 deletions
@@ -0,0 +1,184 @@
---
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()` 终止机制
- [[GIN/3-middleware]] — 中间件三级作用域机制
- [[middleware-abort]] — `c.Abort()` 终止机制