Go Web 服务的 CORS 配置:为什么预检请求总是失败

本文从预检请求的触发机制切入,分析Go Web服务配置CORS时最常见的失败原因,包括响应头缺失、Allow-Headers不匹配、OPTIONS处理不当等,并对比自写中间件、rs/cors库和网关层方案,最后给出curl排查预检失败的具体方法。

为什么浏览器会先发一个 OPTIONS 请求

很多 Go Web 服务都有过这样的经历:接口在 Postman 里一切正常,前端代码一联调就报 CORS 错误,浏览器控制台里看到的全是红色报错。第一次遇到这类问题的人总会在后端代码里翻找半天,却找不到任何异常日志。因为预检请求(preflight)在到达 handler 之前就被拦截了,或者被某个中间件吞掉了响应头。这篇文章想做的就是把预检请求失败这件事拆开来看,看看它到底是怎么产生的,Go 服务里配置 CORS 时最容易漏掉哪些细节,以及再遇到“预检总是失败”时应该从哪几个方向去排查。

Go Web 服务的 CORS 配置:为什么预检请求总是失败

要理解预检请求失败,先得说清楚浏览器为什么非要发一次 OPTIONS。跨域资源共享(CORS)不是一种全新协议,而是浏览器在请求发出前,基于请求特征和服务器响应头做的一套许可校验。当浏览器判断当前请求属于“非简单请求”时,就会先发送一个 OPTIONS 请求,用来确认服务器允不允许真实的请求方法、请求头和请求来源。

什么样的请求会被判定为非简单请求?条件其实很明确:方法不是 GET/HEAD/POST,或者请求头包含自定义字段(比如 Authorization、X-Custom-Header),或者 Content-Type 不是 text/plain、multipart/form-data、application/x-www-form-urlencoded 这三个之一。只要命中其中一个条件,浏览器就会启用预检。

这也就解释了为什么很多服务平时感觉不到 CORS 存在,直到某一天前端往请求里加了 token 之后,跨域问题突然就爆发了。token 通常放在 Authorization 头里,而自定义请求头恰恰会触发预检。所以“加了 JWT 后跨域开始报错”不是错觉,是预检机制开始工作了。

有一点必须明确:CORS 是浏览器的安全限制,不是服务器的安全功能。服务端返回的 CORS 响应头并不会阻止你的 API 被外部程序调用,curl、Python 脚本、手机 App 都仍然能直接访问。浏览器只是在渲染页面时,基于“同源策略”阻止页面脚本读取跨域响应。这一点搞清楚了,就不会再出现“把 CORS 当安全策略用”的误解了。

预检失败通常发生在哪个环节

预检请求本身就是一个 OPTIONS 请求,服务器是否对它进行有效响应,取决于 CORS 中间件能否在路由处理之前介入。Go 服务里最常见的失败场景,几乎都集中在这几个环节。

首先是中间件根本没有覆盖 OPTIONS。很多人只在实际业务 handler 里设置了 CORS 响应头,比如在 GET /api/user 这个处理器里写了几行 Header().Set()。这样当浏览器发出 OPTIONS 请求时,请求落到了路由匹配层,可能直接返回 404 或 405,自然没有 CORS 头。正确的做法是把 CORS 处理放在所有路由之上的全局中间件,让 OPTIONS 请求在进入业务逻辑之前就被处理掉。

其次是响应头设置了,但设置的位置不对。有些 Go 框架,比如 Gin、Echo,都有各自的中间件链。如果 CORS 中间件被放置在其他中间件之后,而前面的中间件直接调用了 w.Write(),那么在写入响应体之后再设置 Header 就没有意义了,因为 HTTP 响应头一旦写入就固定了。这类问题定位起来特别迷惑,因为业务接口看起来一切正常,只有预检请求失败。

第三是反向代理层覆盖了响应头。当 Go 服务部署在 Nginx 后面,而 Nginx 又配置了 add_header 时,需要特别注意 Nginx 的 add_header 默认只有在当前响应同名字头不存在时才会生效。但如果你在 server 或 location 里写得比较乱,比如一个 add_header 在 server 级,一个在 location 级,后者会覆盖前者。很多团队都遇到过“Nginx 配了 Access-Control-Allow-Origin 但接口反而报错”的情况,其实是在代理层把后端的头给替换了。

最常见的三类失败原因

如果把问题再进行归纳,预检失败的原因不外乎就三种:响应头缺失、响应头与请求特征不匹配、OPTIONS 请求被错误地终止或转发。下面逐一细看。

响应头缺失

预检请求失败最容易理解也最容易排查的就是响应头缺失。用 curl 发一个 OPTIONS 请求,如果返回头里没有 Access-Control-Allow-Origin,那浏览器无论如何都不会放行。造成缺失的原因通常是:中间件没有正确注册、某些路径绕过了中间件、Nginx 在代理时 drop 掉了这个头,或者代码里设置头时键名拼写错误。比如把 Access-Control-Allow-Origin 写成了 Access-Control-Allow-Orign,虽然在 Go 里不会报错,但浏览器不认。

响应头与请求特征不匹配

这类问题隐蔽一些。浏览器在预检请求时会带上 Origin、Access-Control-Request-Method、Access-Control-Request-Headers 这几个头,服务器返回的允许列表必须与之匹配。例如前端请求方法是 PUT,但允许列表里只有 GET/POST,预检就会失败。同理,如果前端使用了 Authorization 头,而 Allow-Headers 里没写,也会失败。

这里有个反直觉的细节:Allow-Headers 不是“允许的额外头”,而是“允许请求头列表”,它必须包含前端所有非简单请求头的名字。很多团队在配置时只写了 Content-Type,忽略了 token 用的 Authorization,结果一加上鉴权就跨域报错。

另一个匹配问题是 Origin 匹配。如果你配置了 Allow-Origins 为具体域名,那么浏览器发来的 Origin 必须与之完全一致(协议、域名、端口都不能错)。虽然短期内允许所有来源比较简单,但只要涉及到 Cookie 和 Authorization,就得用具体域名。

OPTIONS 请求被错误处理

第三个原因是对 OPTIONS 本身处理不当。比如有些代码直接对 OPTIONS 请求 return 200,但没设置任何 CORS 头。浏览器拿不到允许信息,一样认为失败。也有代码把 OPTIONS 请求继续往下传,最后进入某个 404 路由,返回的响应里自然没有相关头。

在 Go 标准库 net/http 中,如果你使用 http.ServeMux,默认对 OPTIONS 会返回 200,但响应头是空的。所以很多人会看到“状态码 200,但还是跨域失败”的情况。这个 200 会误导排查,觉得服务器已经响应了,其实缺少的是多个响应头。

自写中间件和成熟库的取舍

在项目里落地 CORS,通常有两种路径:自己写一个中间件,或者引入 rs/cors。对于规模较小的内部服务,自己写完全可以;一旦服务开始涉及多环境、多域名、凭证传输,我还是建议用库。下面先看一个自写中间件的最小示例。

func CORS(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

这段代码能覆盖一部分场景,但问题也不少:Allow-Origin 固定为 *,无法响应 withCredentials;没有处理 Allow-Credentials;也没有校验实际的 Origin。所以生产环境里我更倾向于用 rs/cors。下表从几个维度进行了对比。

对比项 自写中间件 rs/cors 库 网关层
实现成本
灵活性 可控但容易出错 配置丰富 依赖网关表达能力
维护风险 高,容易漏头 低,社区维护 中,需要了解网关机制
适用规模 内部小工具、临时服务 面向多域名的业务服务 多服务统一接入的团队

再说说 rs/cors 里几个容易被忽略的配置项。下面是一份常用的配置:

c := cors.New(cors.Options{
    AllowedOrigins: []string{"https://admin.example.com"},
    AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
    AllowedHeaders: []string{"Authorization", "Content-Type"},
    AllowCredentials: true,
    MaxAge: 12 * time.Hour,
})
handler := c.Handler(router)

第一个是 OptionsPassthrough。默认情况下,rs/cors 会直接截断 OPTIONS 请求并返回 204,不会继续调用后续 handler。如果把它设为 true,那么 OPTIONS 请求在设置响应头后还会继续向下传递,进入你的业务路由。这个配置有时候会把事情搞复杂,比如你在业务代码里又写了对 OPTIONS 的处理,造成冲突。所以除非确实需要处理 OPTIONS,否则保持默认即可。

第二个是 OptionsResponseStatusCode,默认值是 200。有些浏览器要求预检请求返回 2xx 状态码,rs/cors 默认用 200,也有的团队喜欢改成 204。实际上两者都可以,关键是必须有 CORS 头。

第三个是 AllowPrivateNetwork,这是后来新增的选项,用于支持私有网络访问规范。如果你的前端页面和后端 API 处在不同的私有网段,Chrome 等浏览器可能还会发送额外的检查,这个配置项也能解决一部分奇特的失败问题。不过在普通公网服务里很少用到。

几个很常见的“反直觉”坑

写 CORS 配置最怕的就是根据开源项目里流传的例子照搬。下面这几个坑,几乎每隔一段时间就会在技术群里看到一次。

  • 把 Access-Control-Allow-Origin 设置为 *,同时又在代码里设置了 Cookie。只要响应头里出现了 Access-Control-Allow-Credentials: true,浏览器就要求 Allow-Origin 必须是具体值,不能是 *。反过来也一样,用了 * 就不能再用 Credentials。
  • 对 OPTIONS 请求返回自定义的业务错误码,比如 401。前端代码看起来是鉴权失败,其实只是因为预检请求缺少必要的 CORS 响应头,导致浏览器提前拦截。不要在后端业务逻辑里对 OPTIONS 做鉴权。
  • 在 Gin 里同时使用 gin.CORS 中间件和 Nginx add_header。如果两边都对跨域头负责,Nginx 的 add_header 会覆盖 Gin 设置的同名字头,或者反过来,取决于代理配置的写法。最稳妥的做法是只让一层来管理 CORS 响应头,避免双重设置引起混乱。
  • 把 MaxAge 设得过大或过小。MaxAge 是告诉浏览器预检结果可以缓存多久。如果设得太小,每次请求都要重新预检,性能差一些;如果设得太大,浏览器会有一段时间不再发送预检,但服务端改了配置后,要等缓存过期才生效。一般建议 4 到 12 小时都可以,调试阶段可以设为 0,强制每次都发预检。

预检失败时怎么排查

排查预检问题,最快的方式是直接用 curl 模拟一次预检请求,拿到服务端真实响应。这里有一个标准模板:

curl -i -X OPTIONS 'https://api.example.com/v1/order' -H 'Origin: https://admin.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: Authorization, Content-Type'

把域名、路径、请求方法、请求头换成你自己的场景。然后观察响应头里是否包含这三项:

  • Access-Control-Allow-Origin:需要和 Origin 头一致(或为 *,但 * 不允许配 Credentials)。
  • Access-Control-Allow-Methods:必须包含实际请求方法。
  • Access-Control-Allow-Headers:必须包含 Access-Control-Request-Headers 里列出的所有请求头。

如果响应头和预期一致,再把 Access-Control-Allow-Credentials 考虑进来。前端如果设置了 withCredentials,那么服务端必须返回 Allow-Credentials: true,并且 Allow-Origin 不能是 *。如果这一步也通过,预检基本就没问题了。

此外,优先让 CORS 中间件放在最外层,保证所有响应都带上统一的 CORS 头。然后再去看框架本身有没有对 OPTIONS 做额外的路由匹配。比如 net/http 自己的 mux 遇到 OPTIONS 会返回空 200,而 Gin 会根据路由返回 404 或找不到对应方法,这些行为都要提前了解,否则很容易在排查时被误导。

如果服务已经接了网关

很多团队发展到一定阶段后会把 Nginx、Kong、APISIX 这类网关作为统一入口。这时候 CORS 更应该放在网关层来做,而不是每个后端服务各写一份。好处是规则集中,所有后端不需要关心重复配置,也避免不同服务之间的跨域策略不一致。

但放到网关层也有一个反噬风险:网关的 add_header 只会作用在特定上下文,一旦某个路由上的代理返回了包含同名响应头的后端响应,add_header 可能不会追加,导致你看起来配置了却没有生效。正确的做法是结合网关的“删除响应头”功能,先删除后端返回的 CORS 相关头,再统一添加,或者在网关层直接由插件接管。这个度需要根据具体网关的文档来调整。

写在后面

预检请求失败在 Go Web 服务里是一个被问了太多年的问题,但它的核心始终没有变:浏览器只认响应头,不认你的业务逻辑。只要理解预检机制,把 CORS 中间件放在正确位置,并且配置好 Allow-Origin、Allow-Methods、Allow-Headers 这三件套,绝大部分问题都能在一两分钟内定位。真正的复杂度通常来自“多个配置层叠加”,比如框架中间件、代理服务器、前端请求特征三方之间互相牵制。下次再遇到“预检总是失败”时,不妨先用 curl 把响应头打开看看,然后再一层层排查中间件顺序和代理配置,往往很快就能找到真相。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/950/

(0)
上一篇 18小时前
下一篇 4小时前

相关推荐