跨域 CORS:原理与实现
本章解决什么问题:前后端分离时,浏览器页面与 API 不同源,直接 fetch 会被同源策略拦截。CORS 是服务端通过响应头“声明允许哪些跨域请求”的协议。本章讲原理,给一个可用的手写中间件,再提社区库的配置思路。
CORS 是什么
浏览器默认阻止页面读取跨源响应。服务器通过响应头显式放行:核心是 Access-Control-Allow-Origin 声明允许的来源。请求涉及凭据(cookie、Authorization 头)时还需要 Access-Control-Allow-Credentials: true,且此时 Allow-Origin 不能是 *(浏览器强制要求具体来源)。
简单请求与预检
- 简单请求(GET/POST/HEAD + 少量安全头 + 表单编码)直接发出,服务端用响应头放行即可;
- 其他情况(自定义头、PUT/DELETE、application/json 等)浏览器会先发一个 OPTIONS 预检请求,确认 Allow-Methods/Allow-Headers 后才发真实请求。
因此 CORS 中间件要做两件事:给响应打上放行头;对预检 OPTIONS 直接 204 收尾,不进入业务路由。
手写一个 CORS 中间件
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin) // 回显来源
c.Header("Vary", "Origin")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-Id")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "86400") // 预检结果缓存 24 小时
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent) // 预检到此为止
return
}
c.Next()
}
}
func main() {
r := gin.Default()
r.Use(CORS()) // 全局限流,也可只挂到需要开放跨域的组
r.GET("/profile", func(c *gin.Context) { c.JSON(200, gin.H{"name": "gin"}) })
r.Run(":8080")
}
“回显 Origin + Vary: Origin”是带凭据且来源动态变化时的常见写法;如果 API 只服务固定的几个前端域名,更稳妥的是先对来源做白名单匹配,命中才回显。
常见库的配置要点
不想手写可用 gin-contrib/cors:它把上述响应头都收敛成配置项——AllowOrigins、AllowMethods、AllowHeaders、AllowCredentials、MaxAge,还提供 AllowAllOrigins 等快捷选项。不同版本字段细节以该库文档为准,原理与本例一致,读懂响应头就能读懂配置。
安全提示
- 不要简单“全部放行”:Allow-Origin: * 与 credentials 同时使用会被浏览器拒绝,也意味着任意站点都能读你的接口。
- 预检请求应尽快返回,一般不带用户凭据,别让它在业务日志与鉴权里制造噪音。
- CORS 只是浏览器侧策略,服务端鉴权、限流仍然必须做。
关键点
- 跨域的解法是“服务端声明 + 浏览器执行”,头没打对前端就报 CORS 错。
- OPTIONS 预检是必经之路,中间件要在业务路由前把它 204 收掉。
- 带凭据时不能使用通配来源,需要精确的 Origin 白名单。
小结
CORS 的本质是几组响应头加一次预检拦截,理解后既能手写,也能读懂 gin-contrib/cors 的配置。下一章回到请求侧,看 binding 校验的常用 tag 家族。