curl 能通、浏览器报错?这不是 Bug,是 CORS
译注:本文编译自 dev.to 文章《Your API works in curl but not in the browser — that's CORS》,作者以自己为 Go helpdesk API 编写 Vue 前端的经历,解释了跨域问题的成因与一套不依赖第三方库的中间件实现。原文链接见文末。
你写好了一个 API,用 curl 测试每个端点都返回正常。然后你接上前端,控制台却抛出这样的错误:
Access to fetch at 'http://localhost:8080/api/login' from origin
'http://localhost:5173' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check.什么都没有坏,你的 API 也没问题——curl 从来就不是有效的测试手段。
curl 不是浏览器
curl 发出请求,然后把响应交给你。它不关心请求来自哪里,因为 curl 根本没有「页面」这个概念。
浏览器有。它知道调用 fetch 的 JavaScript 来自 http://localhost:5173,也知道你在请求 http://localhost:8080。端口不同就意味着 origin 不同,而默认情况下,一个页面只能调用自己所属的 origin。
这条规则的存在有充分理由:没有它,你访问的任何网站都能悄悄用你浏览器里已有的 Cookie 去调用 yourbank.com/api/transfer。这个拦截是在保护用户,而不是在给你添麻烦。
因此,CORS(Cross-Origin Resource Sharing,跨域资源共享)就是服务端用来表态的机制:「没问题,我知道那个 origin,放它过来。」
由此可以推出两点,也正是人们最容易搞错的两点:
授权来自服务端,而不是客户端。 你无法在前端代码里修复 CORS。任何让你改 fetch 选项的「修复方案」,要么是错的,要么本质上是代理。
执行者是浏览器,而不是服务端。 请求往往已经到达了你的 API,API 往往也已经返回了响应,然后浏览器在 JavaScript 看到它之前把响应丢掉了。这就是为什么服务端日志显示一个完全正常的 200,而控制台却显示失败。第一次遇到时非常令人困惑。
预检请求(Preflight)
对于简单 GET 请求,浏览器直接发送请求,然后检查响应里有没有授权头。
对于其他情况——带 JSON 的 POST、PATCH、DELETE,或任何带 Authorization 头的请求——浏览器会先问一次。这就是预检:向同一 URL 发一个 OPTIONS 请求,意思是「我来自这个 origin,我想用这个方法,我想带这些头——可以吗?」
你的服务端必须用授权头回应这个 OPTIONS 请求。如果不回应,真正的请求根本不会被发出。
这也解释了为什么很多人困惑于「GET 能用,POST 却不行」。
中间件实现
下面是完整的 Go 实现,只用标准库:
func withCORS(allowed []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && originAllowed(origin, allowed) {
w.Header().Set("Access-Control-Allow-Origin", origin)
// the reply changes with the Origin, so caches must key on it
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods",
"GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers",
"Authorization, Content-Type")
w.Header().Set("Access-Control-Max-Age", "86400")
}
// a preflight is answered here and never reaches a handler
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}用它包住你的路由:
func (a *App) routes() http.Handler {
mux := http.NewServeMux()
// ... all your routes ...
return withCORS(a.AllowOrigins, logRequests(mux))
}其中有四个细节值得展开说明:
Access-Control-Allow-Headers 必须列出 Authorization。 这是「我加了 CORS 但还是不行」最常见的单一原因。如果你的 API 使用 bearer token,而这个头没有提到 Authorization,浏览器就会拒绝发送 token,所有需要鉴权的调用都会失败。
回显 origin,不要硬编码。 在确认 origin 被允许之后,把它原样回显,这样一次部署就能服务多个前端。
Vary: Origin 不是可选项。 你的响应现在会因请求方不同而不同。没有这个头,缓存可能把某个 origin 的响应存下来发给另一个 origin,导致只在生产环境、且只偶尔出现的故障。
Max-Age 能省掉预检开销。 没有它,浏览器会对每一个 POST 做预检;有了它,一天只问一次。
永远不要用 *
所有教程都写 Access-Control-Allow-Origin: *。它确实能用,但含义是互联网上任何网站都可以调用这个 API。
对公开的只读 API 来说没问题;对任何带鉴权的接口都不行——浏览器也清楚这一点,所以涉及凭证时 * 会被直接忽略。
应该从配置里读取允许的 origin:
origins := strings.Split(getenv("ALLOW_ORIGINS", "http://localhost:5173"), ",")开发环境用 localhost,生产环境用真实域名,同一份代码。
不靠浏览器也能测
不必靠猜,自己发一个预检请求:
curl -i -X OPTIONS localhost:8080/api/login \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: POST'你期望看到:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin这正是浏览器在每个 POST 之前做的事。如果这一步通过,浏览器也会通过。
而且因为它只是几个头,完全可以自动化测试:
func TestCORS(t *testing.T) {
srv := newTestServer(t)
const allowed = "http://localhost:5173"
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/login", nil)
req.Header.Set("Origin", allowed)
res, _ := http.DefaultClient.Do(req)
if res.StatusCode != http.StatusNoContent {
t.Errorf("preflight status: got %d, want 204", res.StatusCode)
}
if got := res.Header.Get("Access-Control-Allow-Origin"); got != allowed {
t.Errorf("allow-origin: got %q, want %q", got, allowed)
}
// an origin we did not allow must get nothing
req2, _ := http.NewRequest("OPTIONS", srv.URL+"/api/login", nil)
req2.Header.Set("Origin", "http://evil.example")
res2, _ := http.DefaultClient.Do(req2)
if got := res2.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("unknown origin was allowed: %q", got)
}
}后半段比前半段更重要。很容易写出一个不小心放行一切的 CORS 中间件,而只测正常路径的测试会愉快地通过。
生产环境最容易踩的坑
前端在本地跑得好好的,一部署就坏。
原因通常是 ALLOW_ORIGINS 还停留在 localhost,把它改成真实域名即可。
顺带一提:如果你的前端使用客户端路由,记得让静态托管服务对未知路径返回 index.html,否则刷新 /tickets/1 会得到 404。这是另一个问题,但常常发生在同一个部署日,同样令人头疼。