Go 1.25 在 net/http 里加了 CrossOriginProtection,它能帮我们挡住一类常见的 CSRF 风险。但我不建议你把它当成“套一下就安全”的中间件。真正上线时,最容易出事的地方往往不是 Handler 怎么包,而是 GET 写状态、可信 Origin 配错、绕过白名单太宽、拒绝日志没有打。
先说业务场景:后台接口为什么会被 CSRF 打中
假设你有一个管理后台,用户登录后浏览器里带着 Cookie。攻击者诱导这个用户打开另一个网页,那个网页偷偷提交一个表单或者发起一个跨站请求。如果你的接口只看 Cookie,不看请求是不是从可信页面发过来的,就可能把“别人页面上的请求”当成正常用户操作。
这类问题在 Go 项目里并不少见,尤其是早期后台系统:前后端同域时没感觉,后来前端拆到独立域名、管理后台又接了几个合作方入口,Origin 规则就开始变得模糊。等到安全扫描报 CSRF,团队才发现很多 POST、PUT、DELETE 接口压根没统一防护。
CrossOriginProtection 到底做了什么
官方文档里说得很克制:CrossOriginProtection 会拒绝非安全的跨源浏览器请求。它主要靠现代浏览器的 Sec-Fetch-Site 头,或者比较 Origin 头里的主机和 Host 来判断跨源。GET、HEAD、OPTIONS 这类安全方法默认放行,所以你的业务代码必须保证这些方法不改状态。
这点非常关键。CrossOriginProtection 不是替你修业务语义的。你如果把“确认订单”“删除配置”“切换开关”写在 GET 里,它会被当成安全方法放过去。安全不是靠一个库补锅,而是靠接口语义、浏览器信号和服务端校验一起收口。
推荐接法:先包入口,再配可信来源
我一般会把它放在路由最外层,覆盖所有需要浏览器访问的写接口。然后只把真实需要跨源访问的前端域名加到可信来源里,要求精确到 scheme、host 和可选端口。不要为了图省事写一堆“看起来差不多”的域名。
func buildHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/orders/{id}/confirm", confirmOrder)
mux.HandleFunc("POST /api/profile/email", updateEmail)
mux.HandleFunc("GET /healthz", healthz)
cop := http.NewCrossOriginProtection()
// 只放真正需要跨站调用的前端域名,要求精确匹配 scheme://host[:port]。
if err := cop.AddTrustedOrigin("https://app.example.com"); err != nil {
log.Fatalf("bad trusted origin: %v", err)
}
if err := cop.AddTrustedOrigin("https://admin.example.com"); err != nil {
log.Fatalf("bad trusted origin: %v", err)
}
cop.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("csrf denied origin=%q sec_fetch_site=%q method=%s path=%s",
r.Header.Get("Origin"),
r.Header.Get("Sec-Fetch-Site"),
r.Method,
r.URL.Path,
)
http.Error(w, "forbidden", http.StatusForbidden)
}))
return cop.Handler(mux)
}
这里我故意加了 SetDenyHandler。默认 403 没错,但线上没有拒绝日志就很难灰度。你需要知道被拒绝的是哪个 Origin、哪个 Sec-Fetch-Site、哪个方法、哪个路径。否则一上线发现前端报错,你只能猜是 CORS、Cookie SameSite、反向代理还是 CSRF 防护。
危险写法:绕过白名单不是万能胶
CrossOriginProtection 提供 AddInsecureBypassPattern,是为了兼容某些确实不适合做跨源校验的路径。但这个名字里带着 Insecure,不是随便叫的。你越想用它省事,越应该停下来做一次接口盘点。
// 坏味道:为了省事,把大量接口加进绕过白名单。
cop := http.NewCrossOriginProtection()
cop.AddInsecureBypassPattern("/api/")
mux.HandleFunc("GET /api/order/confirm", func(w http.ResponseWriter, r *http.Request) {
// GET 请求里改订单状态,CSRF 防护默认会把 GET 视为安全方法放行。
confirm(r.Context(), r.URL.Query().Get("id"))
w.WriteHeader(http.StatusOK)
})
更现实的一点是:Go 1.25.0 里 AddInsecureBypassPattern 曾有过安全修复,官方漏洞报告说明它可能比预期绕过更多请求,修复版本是 Go 1.25.1 之后。因此只要你准备在生产使用 CrossOriginProtection,第一件事不是改代码,而是确认 Go 版本至少已经包含这个修复。
预发怎么测:别只测正常页面
我会在预发里准备三类请求。第一类是同源正常请求,必须通过。第二类是可信 Origin 的跨源请求,比如管理后台独立域名,也必须通过。第三类是恶意 Origin 或 Sec-Fetch-Site=cross-site 的写请求,必须返回 403,而且日志里能看到拒绝原因。
func TestCrossOriginProtection(t *testing.T) {
h := buildHandler()
req := httptest.NewRequest(http.MethodPost, "/api/orders/100/confirm", nil)
req.Host = "api.example.com"
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d", rr.Code)
}
}
单元测试只是一层保险。更重要的是用浏览器真实压一次,因为 Sec-Fetch-Site、Origin、Cookie SameSite、代理改头这些东西,在 httptest 里很容易测得太理想。预发最好走完整链路:前端域名、网关、服务、日志、告警都一起验证。
上线前我的检查清单
- 所有会改状态的接口都不是 GET、HEAD、OPTIONS。
- 可信 Origin 清单来自真实业务域名,不包含通配式想象。
- 绕过白名单只用于非常明确的路径,并且有代码注释说明原因。
- 拒绝请求有日志和指标,至少能按 path、method、origin 聚合。
- Go 版本已经包含 CrossOriginProtection 相关安全修复。
- 预发验证同源、可信跨源、恶意跨源三类请求。
它和 CORS、SameSite Cookie 不是一回事
很多团队会把这几个概念混在一起。CORS 是浏览器读响应的跨源权限模型;SameSite Cookie 控制 Cookie 在跨站请求里的发送策略;CrossOriginProtection 是服务端在收到请求后主动判断并拒绝不安全跨源写请求。它们可以配合,但不能互相替代。
我建议把 CrossOriginProtection 当成“服务端最后一道简单可靠的浏览器跨源写请求闸门”。前面仍然要有合理的 Cookie SameSite、CORS 策略、权限校验、审计日志。安全做得稳,不是因为某一个点特别强,而是每一层都不偷懒。
最后聊两句
Go 标准库给 CrossOriginProtection,我觉得是件好事。它让很多中小项目不必一上来就引一套复杂 CSRF 方案,也能把最常见的跨源写请求挡住。但标准库给的是能力,不是上线方案。
真正落地时,按我的经验,先盘点写接口,再接入 Handler,配准可信 Origin,少用绕过白名单,把拒绝日志打全,最后灰度观察。这样用 CrossOriginProtection,才像是在做生产安全,而不是给代码贴一张“已防护”的标签。