CORS 跨域资源共享安全配置实战:预检请求、凭证携带与 Nginx 头收口,别把 Access-Control-Allow-Origin 写成通配

一个被误配的 CORS 头,如何让整站数据裸奔

对个人站长来说,CORS(跨域资源共享)通常是"前端调不通接口时随手加的一个头"。网上搜到的答案十有八九是这一句:Access-Control-Allow-Origin: *。加完接口通了,问题"解决"了,然后就被忘在配置里。但这一行恰恰是 Web 安全里最高频的踩坑点之一——如果你的接口携带 Cookie 做鉴权,那么 * 会导致浏览器直接拒绝请求(凭证模式不允许通配),你只能退而求其次写死来源;而如果你没意识到凭证模式的存在,随便写了个来源,就等于把用户数据开放给了任何能构造请求的页面。

本文从浏览器同源策略讲起,把 CORS 的预检、凭证、头收口一次讲清,并给出 Nginx 上的安全配置模板。所有配置都贴近个人站真实场景,不做理论堆砌。

先搞懂同源策略:浏览器到底在拦什么

同源策略(Same-Origin Policy)是浏览器的第一道防线。所谓同源,就是协议、域名、端口三者全部相同。注意几个容易被忽视的判定规则:

https://a.com 与 https://a.com/api      → 同源(路径不参与判定)
https://a.com 与 http://a.com           → 跨域(协议不同)
https://a.com 与 https://a.com:8443     → 跨域(端口不同)
https://a.com 与 https://b.a.com        → 跨域(子域名不同)
https://a.com 与 https://a.com.         → 看浏览器,尾点通常视为跨域

很多人以为"跨域是服务器拦住了请求",其实相反:请求往往已经发出去了,是浏览器拦截了响应。 这一点至关重要,因为它决定了两个安全结论:第一,简单请求(GET/POST 且 Content-Type 是表单类型)哪怕被 CORS 拦截,请求在服务器端依然执行了——这就是 CSRF 攻击的基础;第二,要防的是"别的站点通过用户浏览器读取你的数据",而不是"请求没发出去"。

简单请求与预检请求的分界

CORS 把请求分成两类,配置方式完全不同,这是最容易混淆的地方。

简单请求必须同时满足:方法是 GET / HEAD / POST 之一;请求头只包含安全列表内的字段(Accept、Accept-Language、Content-Language、Content-Type 等);Content-Type 只能是 application/x-www-form-urlencoded、multipart/form-data、text/plain 三者之一。任何一条不满足,浏览器就会先发一个 OPTIONS 预检请求,问服务器"我能不能用这个方法和头来访问"。只有预检返回了许可,真正请求才会发出。

# 一个典型的"看起来简单其实要预检"的请求
fetch('/api/save', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },  // 这一行触发了预检
  body: JSON.stringify({ title: 'hi' })
});

因为 application/json 不在简单请求的 Content-Type 白名单里,这个请求一定会先发 OPTIONS。很多站长遇到"OPTIONS 请求 405"、"预检失败"却不知道从哪来,答案就在这里。

预检请求的完整往返与必配响应头

预检请求会带上三个关键请求头:

Origin: https://other-site.com           # 发起方来源
Access-Control-Request-Method: POST      # 想用的方法
Access-Control-Request-Headers: content-type, authorization  # 想带的头

服务器必须在 OPTIONS 响应里回三个头,缺一不可:

Access-Control-Allow-Origin: https://other-site.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

如果预检响应里 Access-Control-Allow-Headers 漏了 authorization,浏览器会直接判定预检失败,真正的请求根本不会发出,控制台报的错是 Request header field authorization is not allowed by Access-Control-Allow-Headers。这类报错信息很具体,照着补齐即可,别被"跨域失败"四个字带偏。

还有一个性能细节:预检结果会被浏览器缓存,缓存时长由响应头 Access-Control-Max-Age 控制。默认值各浏览器不同(Chrome 5 秒、Firefox 24 小时),不设这个头,等于每个跨域请求都要多一次 OPTIONS 往返。对于频繁调接口的前端,把 Access-Control-Max-Age: 86400 加上,能直接砍掉一半的请求数。

凭证模式:为什么 * 和 Cookie 不能共存

当请求需要携带 Cookie 或 HTTP 认证信息时,前端必须显式开启:

fetch('/api/me', { credentials: 'include' });

此时服务器端的 Access-Control-Allow-Origin 绝对不能是 *,必须回显具体的 Origin,同时还要加上:

Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Credentials: true
Vary: Origin

这就是"* 凭证不兼容"规则的由来。浏览器认为通配符无法表达"只信任特定来源",所以在带凭证的请求里拒绝 *。如果你把接口配成 * 又期望 Cookie 生效,前端会收到 The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'。

更隐蔽的是 Vary: Origin 这个头。它告诉 CDN 和反向代理"同一个 URL 的响应会随 Origin 变化",从而避免缓存串味——A 站拿到的 Access-Control-Allow-Origin: https://a.com 被缓存后回给了 B 站。如果你的接口动态回显 Origin 却没加 Vary: Origin,CDN 命中时会出现"偶发跨域失败",而且极难复现。这是排查跨域问题时最该先看的一处。

Nginx 上的安全配置模板

下面这份配置覆盖了静态站和动态接口两种场景,核心思想是白名单映射而不是回显任意 Origin。回显任意 Origin(if ($http_origin) { add_header ... $http_origin })看似方便,实则等价于对全世界开放,是必须避免的写法。

map $http_origin $cors_origin {
    default                                        "";
    "~^https://(www\.)?mysite\.com$"               $http_origin;
    "~^https://admin\.mysite\.com$"                $http_origin;
    "~^http://localhost:(3000|5173)$"              $http_origin;  # 本地开发
}

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

    # 预检请求:直接在这里吃掉,不落到后端
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin      $cors_origin always;
        add_header Access-Control-Allow-Methods     "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers     "Content-Type, Authorization, X-Requested-With" always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Access-Control-Max-Age           86400 always;
        add_header Vary                             "Origin" always;
        return 204;
    }

    location /api/ {
        add_header Access-Control-Allow-Origin      $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Vary                             "Origin" always;
        proxy_pass http://127.0.0.1:9000;
    }
}

三个要点:第一,用 map 把允许的来源写成白名单正则,只有命中的才回显,其余一律回空字符串,浏览器自然拒绝。第二,add_header 结尾的 always 参数很关键——没有它,当响应是 4xx/5xx 时头不会加上,前端拿到错误响应还看不到跨域信息,排查时会以为"跨域失败"掩盖了真正的 500 错误。第三,return 204 让预检请求在 Nginx 层直接返回,不消耗后端资源,对高并发接口是实打实的减负。

常见误配与对应的安全后果

误配写法                                    安全后果
Access-Control-Allow-Origin: *  + Cookie    浏览器拒绝,接口实际不可用
动态回显任意 Origin + 凭证                  任何网站都能读取用户数据(高危)
Allow-Origin 反射但不加 Vary                 CDN 缓存串味,偶发跨域失败
Allow-Credentials: true 与通配并存          本身非法,浏览器会拦截
把 Origin 判断写在 location 但预检未处理     预检 405,请求发不出
Allow-Headers 漏 authorization              带 token 的请求全部失败

其中最危险的是"动态回显任意 Origin + Allow-Credentials: true"。它等价于关闭了同源策略:攻击者在自己站点用 JS 发请求,用户浏览器带上目标站 Cookie,服务器回显攻击者的 Origin 并允许凭证,于是攻击者能读走用户的全部数据。这种配置在一些教程里被当作"跨域解决方案"广泛传播,是必须警惕的反面教材。

排查跨域问题的正确顺序

遇到跨域报错不要猜,按下面顺序看浏览器 DevTools 的 Network 面板:

1. 是否先发出了 OPTIONS?没有 → 是简单请求,看真正请求的响应头
2. OPTIONS 返回状态码?405/404 → 后端或 Nginx 没处理预检
3. 响应头里 Allow-Origin 是 * 还是具体值?带凭证时必须是具体值
4. Allow-Headers 是否覆盖了前端实际发送的所有自定义头
5. 带凭证时报错 → 检查 Allow-Credentials 与 Allow-Origin 的组合
6. 偶发失败 → 检查 Vary: Origin 与 CDN 缓存
7. 响应是错误码时头丢失 → 检查 add_header 是否带了 always

CORS 的本质是浏览器代替用户做的一次授权判断,配置的核心不是"让请求通过",而是"只让可信来源通过"。个人站最常见的问题不是不懂技术,而是从网上抄了一句 * 就再没检查过。花十分钟把上面的白名单 map 和 always 参数补上,再确认一下 CDN 缓存没有串味,你的接口就从"裸奔"回到了"只对自家前端开门"的状态。记住那条最简单的判断准则:任何需要携带 Cookie 的接口,Allow-Origin 都必须是一个具体的、写死在白名单里的来源。

CORS 常见问题速查

问:为什么我加了 Access-Control-Allow-Origin 还是跨域失败?
答:先看是不是预检请求。带自定义头或 application/json 的请求会先发 OPTIONS,如果 OPTIONS 响应里没有正确的 Allow-Origin、Allow-Methods、Allow-Headers,真正的请求根本不会发出。在 DevTools 里过滤 OPTIONS 看那次请求的响应头。

问:用 * 为什么会报 "must not be the wildcard"?
答:因为前端开了 credentials: 'include'。带凭证的请求不允许通配来源,服务器必须回显具体 Origin 并加 Allow-Credentials: true。若接口不需要 Cookie,改用基于 Token 的头部鉴权,就能继续用 *。

问:本地开发 localhost 调不通线上接口怎么办?
答:把 http://localhost:3000、http://localhost:5173 加进白名单 map 即可,但务必只对开发环境生效,别把 localhost 泄漏到生产配置里。更干净的方式是用 Nginx 反向代理,让本地请求同源。

问:CDN 缓存会不会破坏 CORS?
答:会。如果服务器动态回显 Origin 却没加 Vary: Origin,CDN 会把 A 站的响应缓存后回给 B 站,造成偶发失败。给所有回显 Origin 的接口都加 Vary: Origin,并在 CDN 侧确认该头被透传。

问:OPTIONS 请求落在后端 405 怎么修?
答:在 Nginx 层用 if ($request_method = OPTIONS) { return 204; } 直接吃掉预检,既修了 405,又省下后端开销。注意 add_header 都要带 always。

检查清单:上线前过一遍

[ ] 需要凭证的接口没有使用通配 Origin
[ ] 白名单用 map 正则实现,而非回显任意 Origin
[ ] 所有 add_header 都带了 always 参数
[ ] OPTIONS 预检在 Nginx 层返回 204
[ ] Allow-Headers 覆盖了前端实际发送的所有头
[ ] 回显 Origin 的响应都带 Vary: Origin
[ ] 设置了 Access-Control-Max-Age 减少预检往返
[ ] 本地开发来源只对 staging 生效
[ ] 上线后从第三方域名实测一次跨域请求
Last modification:October 12th, 2026 at 01:25 pm

Leave a Comment