Files
cs-note/hhs/GIN/3-middleware/cors-registration-scope/cors-preflight.md
T
2026-05-24 11:42:38 +08:00

6.9 KiB
Raw Blame History

tags, create time
tags create time
后端
Go
Gin
CORS
跨域
预检
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),就可以省去预检往返。

关联笔记