侧边栏壁纸
  • 累计撰写 152 篇文章
  • 累计收到 2 条评论

Nginx 配了 CORS 跨域为什么总报错?排查 OPTIONS 预检拦截、Header 重复与 always 缺失我踩过的几个坑

2026-10-3 / 0 评论 / 10 阅读
AI 摘要由 AI 生成

Nginx配置CORS时,常见问题包括OPTIONS预检请求错误处理和Header重复。文章指出,OPTIONS请求需Nginx直接响应204状态码,避免405错误;多层反代可能导致Header重复,需注意配置。

前端在本地联调或者跨域名调用后端接口时,控制台突然跳出红色警告:Access to fetch at 'https://api.example.com' from origin 'https://app.example.com' has been blocked by CORS policy。

遇到这种情况,很多人第一反应就是在 Nginx 反向代理层随手加上一行配置:

add_header Access-Control-Allow-Origin *;

以为这样就能一劳永逸解决跨域,结果代码推上线之后,问题反而变得更诡异:有的接口报 405 Method Not Allowed,带 Cookie 的登录接口直接被拦截,更离谱的是后端抛出 500 异常时前端完全拿不到错误返回体,控制台只显示跨域失败。

跨域本身并不复杂,但 Nginx 处理响应头和 HTTP 协议细节时有几个反直觉的设计。这里把几个排查过的典型问题和正确的配置方式整理出来。

坑一:OPTIONS 预检请求返回 405 或者被错误透传

很多前端项目使用的都是 Axios 或原生 fetch,请求头里通常包含 Content-Type: application/json,或者带了自定义的 Authorization 认证头。

按照 W3C 规范,这类请求属于“非简单请求”(Non-Simple Request)。浏览器在真正发送 GET 或 POST 业务数据之前,会强制先发送一个探测性质的 OPTIONS 请求,也就是预检请求(Preflight Request)。预检请求通过之后,浏览器才会发出真正的业务请求。

问题就出在这里:很多后端的路由规则只注册了 GET 和 POST。如果 Nginx 没有对 OPTIONS 做单独拦截,而是原封不动把 OPTIONS 请求往上游业务服务器转发,很多后端框架(例如某些 Java 拦截器或早期的 Python 框架)会直接返回 405 Method Not Allowed。

还有一种情况是在 Nginx 里配置了静态资源或特定的 location 匹配,未开启 OPTIONS 支持,导致 Nginx 自身直接响应 405。浏览器收到 405 状态码,认定预检失败,后面的业务 POST 请求直接被掐死。

排查与解法

Nginx 必须在接收到 OPTIONS 预检请求时立即就地响应,无需打扰后端服务,直接返回 204 No Content,并带齐所有预检允许的标头:

if ($request_method = 'OPTIONS') {
    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
    add_header Access-Control-Allow-Headers 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,token' always;
    add_header Access-Control-Max-Age 86400 always;
    add_header Content-Type 'text/plain charset=UTF-8';
    add_header Content-Length 0;
    return 204;
}

这里返回 204 状态码,既避免了转发给上游的开销,也能让浏览器明确知道当前端点支持跨域调用。

可以用 curl 模拟浏览器的预检请求来验证:

curl -I -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Authorization,Content-Type" \
  https://api.example.com/v1/user/profile

观察响应状态码是否为 204,以及 Access-Control-Allow-Methods 与 Access-Control-Allow-Headers 是否正常返回。

坑二:多层反代导致 Header 重复(contains multiple values)

这是微服务架构和多层反代中最常见的事故。

控制台报错通常是这样的一大段:

The 'Access-Control-Allow-Origin' header contains multiple values 'https://app.example.com, https://app.example.com', but only one is allowed.

出现这个报错的原因是:Nginx 的 add_header 指令默认行为是追加,它不会覆盖上游服务返回的同名响应头。

如果在 Spring Boot 项目里已经使用了 @CrossOrigin 注解,或者在 Express、FastAPI 里挂载了全局 CORS 中间件,后端向 Nginx 返回时已经带上了 Access-Control-Allow-Origin。此时 Nginx 的 location 块里又执行了一次 add_header Access-Control-Allow-Origin ...,Nginx 会把这两个头合并发送给浏览器,中间用逗号隔开。

浏览器一旦发现响应头里有两个同名跨域标头,按照安全策略直接认定配置非法并抛出异常。

排查与解法

处理这个问题有两个思路。

首选方案是职责边界清晰化:既然有统一的 Nginx 网关层,跨域逻辑就统统收敛到 Nginx 处理,后端业务代码彻底移除所有跨域配置。

如果后端服务涉及多个团队或者无法立即修改业务代码,可以在 Nginx 的反向代理节点使用 proxy_hide_header 指令,先强制抹去后端吐出来的跨域头,再由 Nginx 统一附加:

location /api/ {
    proxy_pass http://backend_upstream;

    # 隐藏后端上游返回的跨域头,防止与 Nginx 追加的头冲突
    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Allow-Methods;
    proxy_hide_header Access-Control-Allow-Headers;
    proxy_hide_header Access-Control-Allow-Credentials;
    proxy_hide_header Access-Control-Max-Age;

    # 由 Nginx 统一附加合规的响应头
    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Access-Control-Allow-Credentials 'true' always;
}

这样无论后端代码有没有配置跨域,浏览器最终收到的都只有一份由 Nginx 把控的合法标头。

坑三:跨域携带 Cookie 凭证时误用通配符 *

当现代前端应用需要保持登录态,发起请求时开启了凭证模式(例如 Axios 设置 withCredentials: true,或者 fetch 设置 credentials: 'include'),浏览器会严格校验以下两条规则:

  1. Access-Control-Allow-Credentials 必须明确设置为 true。
  2. Access-Control-Allow-Origin 的值*绝对不能是通配符 ``**,必须是具体的 Origin 字符串。

如果 Nginx 里依然写着 add_header Access-Control-Allow-Origin *;,浏览器控制台会直接拒绝:

The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

很多人的第一反应是把配置改成:

add_header Access-Control-Allow-Origin $http_origin;

把客户端传来的 Origin 原封不动回显出去。从功能上看报错确实消失了,但这样等同于对全网任何来源都放行,而且允许携带凭证。恶意网站只需要构造一个隐藏的跨域请求,就能悄悄利用受害者浏览器里的 Cookie 发起攻击。

排查与解法

推荐利用 Nginx 的 map 指令建立受信域名白名单。在 http 块中根据请求头的 $http_origin 动态匹配:

http {
    # 建立白名单映射
    map $http_origin $cors_origin {
        default "";
        "~^https?://(www\.)?example\.com$" "$http_origin";
        "~^https?://app\.example\.com$" "$http_origin";
        "~^http://localhost(:[0-9]+)?$" "$http_origin";
    }

    server {
        # ...
    }
}

在具体的 location 块中引用:

location /api/ {
    # 只有白名单内的域名,$cors_origin 才会有值
    if ($cors_origin != "") {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials 'true' always;
    }

    proxy_pass http://backend_upstream;
}

如果外部未知站点发起请求,$cors_origin 为空字符串,Nginx 不会发送跨域头,浏览器自然阻断;而白名单内的应用和本地开发环境则能正常携带凭证调用接口。

坑四:业务报错 401/403/500 时 CORS 头意外丢失

这是很多人通宵排查 bug 时最容易抓狂的坑。

正常调用接口返回 200 状态码时,一切正常。一旦后端数据库挂了返回 500,或者 Token 失效返回 401,前端页面本该提示“登录已过期”或展示具体的错误信息,结果控制台却是一片刺眼的 CORS 跨域报错。前端同学一口咬定是网关跨域配置有问题,运维和后端看后端日志明明接口已经返回了 401。

翻开 Nginx 官方文档中关于 add_header 指令的说明,能看到这样一句话:

Adds the specified field to a response header provided that the response code equals 200, 201, 204, 206, 301, 302, 303, 304, 307, or 308.

默认情况下,add_header 只对 2xx 和 3xx 这些正常状态码生效。当后端返回 4xx(如 400, 401, 403, 404)或 5xx(如 500, 502, 503)时,Nginx 在返回给客户端之前,会把先前配置的所有 add_header 全部抹掉。

没有了 Access-Control-Allow-Origin 响应头,浏览器就会判定该请求跨域失败,拒绝让前端 JavaScript 读取响应体的内容。这就导致真实的错误原因被跨域异常完全遮蔽。

排查与解法

解决方式非常简单,但往往被遗漏:在每一个跨域相关的 add_header 指令结尾加上 always 参数:

add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials 'true' always;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header Access-Control-Allow-Headers 'Content-Type, Authorization' always;

加上 always 之后,无论后端返回 200、401 还是 500,Nginx 都会保留这些响应头,前端也能顺利拿到真正的业务状态码和 JSON 错误体。

坑五:未配置 Max-Age 导致浏览器每次都发预检请求

很多接口明明只是查询数据,但前端在 Network 面板里看到每个请求耗时都长达两三百毫秒,仔细看会发现每一个 API 前面都紧跟着一个 OPTIONS 请求。

OPTIONS 预检请求本身也是一次完整的 HTTP 网络往返。如果在高延迟的网络环境下,每一次请求都额外增加几十到上百毫秒的消耗,接口吞吐量会明显下降。

通过配置 Access-Control-Max-Age,可以指示浏览器在指定的时间内缓存预检结果:

add_header Access-Control-Max-Age 86400 always;

这里的单位是秒,86400 即代表 24 小时。在缓存期内,相同的请求方法与请求头不需要重复发送 OPTIONS 预检,浏览器会直接发送正式的 GET 或 POST 请求。

需要注意,不同浏览器内核对 Max-Age 的最大上限有硬性限制(例如 Chromium 内核上限通常为 7200 秒即 2 小时,Firefox 为 86400 秒即 24 小时),设置 86400 是通用且稳妥的取值。

生产可用的标准化配置模板

把上面提到的白名单控制、OPTIONS 拦截、always 容错和防重复覆盖整合起来,一个可以直接参考的完整配置如下:

# 1. 在 http 块中配置白名单映射
map $http_origin $cors_origin {
    default "";
    "~^https?://(www\.)?example\.com$" "$http_origin";
    "~^https?://admin\.example\.com$" "$http_origin";
    "~^http://localhost(:[0-9]+)?$" "$http_origin";
}

server {
    listen 443 ssl;
    server_name api.example.com;

    # SSL 证书配置省略...

    location /api/ {
        # 2. 拦截 OPTIONS 预检请求
        if ($request_method = 'OPTIONS') {
            add_header Access-Control-Allow-Origin $cors_origin always;
            add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
            add_header Access-Control-Allow-Headers 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,token' always;
            add_header Access-Control-Allow-Credentials 'true' always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Type 'text/plain; charset=utf-8';
            add_header Content-Length 0;
            return 204;
        }

        # 3. 正常反向代理
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 4. 隐藏上游可能重复吐出的头
        proxy_hide_header Access-Control-Allow-Origin;
        proxy_hide_header Access-Control-Allow-Methods;
        proxy_hide_header Access-Control-Allow-Headers;
        proxy_hide_header Access-Control-Allow-Credentials;
        proxy_hide_header Access-Control-Max-Age;

        # 5. 附加合规响应头(白名单命中时生效,加 always 覆盖异常状态码)
        if ($cors_origin != "") {
            add_header Access-Control-Allow-Origin $cors_origin always;
            add_header Access-Control-Allow-Credentials 'true' always;
        }
    }
}

配置修改完成后,记得在服务器上测试语法并平滑重载:

nginx -t && nginx -s reload

随后可以用带 Origin 请求头的 curl 命令,分别测试正常请求、非法 Origin 请求以及故意触发 500 异常时的标头表现。确认预检 204 秒回、异常状态码带跨域头、凭证和白名单均生效后,再交付给前端调用。

评论一下?

OωO
取消