6.9 KiB
6.9 KiB
tags, create time
| tags | 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" |
思考:为什么同源请求不需要预检?因为同源策略本身就已经限制了跨站操作的安全性,不需要额外协商。
预检的两轮对话
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
① 预检请求 — 浏览器发问
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/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——预检没有响应体,因为还没确认要处理实际请求。
③ 实际请求 — 预检通过后才发
PUT /api/resource HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
X-Token: eyJhbG...
Content-Type: application/json
{"name": "updated"}
如果预检失败(收到非 2xx 或缺少必要 header),浏览器直接静默拒绝,不会发送实际请求。
④ 实际响应 — 浏览器二次校验
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Content-Type: application/json
{"id": 42, "name": "updated"}
浏览器的实际请求不经过预检,但浏览器仍会检查响应中的 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 请求:
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,浏览器认为预检失败就直接阻塞了后续所有请求。
前后端对照
同一接口的完整交互视角(前端发起):
// 前端代码
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()终止机制