一、CORS 到底是什么在保护什么
很多站长对 CORS(跨域资源共享)的理解停留在"报错就加个 Access-Control-Allow-Origin: *"。这个理解既是错的,也很危险。要正确配置它,得先弄清楚它解决的是谁的问题。
浏览器的同源策略规定:网页里的 JavaScript 只能读取同源(协议、域名、端口三者全同)的响应。注意关键词是"读取",跨域的请求本身往往会发出去,服务端也会真的执行——只是浏览器不让发起方的 JS 看到响应内容。CORS 是服务端主动"打开一条缝"的机制:我在响应头里声明"允许 a.com 的页面来读我的东西",浏览器看到这个头,才把响应交给调用方的 JS。
这里有两个极易混淆的概念必须分清。简单请求(GET/POST/HEAD,且 Content-Type 只在 text/plain、application/x-www-form-urlencoded、multipart/form-data 三者之内,无自定义头)会直接发出,服务端照常处理,只是响应被浏览器拦下。所以用 CORS 拦不住 CSRF——请求已经执行了,状态已经改了,只是攻击者读不到结果。真正防 CSRF 要靠令牌或 SameSite Cookie。
非简单请求(PUT/DELETE、带 Content-Type: application/json、带自定义头)会先发一个 OPTIONS 预检,服务端返回允许的方法和头,浏览器确认之后才发真正的请求。预检这一关是能在"执行之前"拦下来的,这也是为什么 JSON 接口天然比表单接口更抗 CSRF。
把这两件事记住,后面的配置就都有依据了:CORS 管的是"谁能读",CSRF 管的是"谁能写",两者不能互相替代。
二、五个响应头的正确含义
一个完整的 CORS 响应通常由这几个头组成,每一个都有它的边界:
Access-Control-Allow-Origin:只能是单个具体源(如https://www.example.com)或*。绝不能是多个源用逗号拼起来,浏览器会直接判无效。需要多源时,服务端读请求的Origin头,与白名单逐条比对后回显那一个。Access-Control-Allow-Credentials: true:允许跨域带上 Cookie。它与*互斥——同时出现时浏览器拒绝响应。这一条是 CORS 最重要的安全约束,下面会专门讲。Access-Control-Allow-Methods:预检时返回允许的方法,如GET, POST, PUT, DELETE。它只影响预检判断,真正的权限控制还得在代码里做。Access-Control-Allow-Headers:预检时声明允许的自定义头,必须覆盖前端实际发送的每一个,漏一个就会预检失败。Access-Control-Max-Age:预检结果缓存多少秒。合理值是 600~3600,太大会让配置变更迟迟不生效,太小则每个请求都多一次 OPTIONS。
还有一个常被忽略的 Vary: Origin。如果你按 Origin 动态回显,响应内容就依赖请求头,必须在响应里加 Vary: Origin,否则 CDN 或反向代理会把 A 站点的响应缓存下来喂给 B 站点,导致 B 的跨域请求时好时坏,甚至把 A 的凭证内容泄露给 B。这是自建站配 CORS 最隐蔽的一个坑。
至于 Access-Control-Expose-Headers,它的作用是让 JS 能读到自定义响应头。默认情况下 JS 只能读 Content-Type、Cache-Control 等少数几个"安全列表"里的头,你返回的 X-Total-Count 分页总数、X-Request-Id 追踪号,不写进 Expose 里前端就拿不到。
三、最危险的配置:反射 Origin 加凭据
网上流传的"通用 CORS 中间件"代码,十有八九长这样:
// 危险写法:谁来都回显,还允许带 Cookie
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
header('Access-Control-Allow-Credentials: true');
这段代码的逻辑是"不管谁访问,我都说可以"。它的后果非常严重:攻击者在自己的站上放一段 JS,让受害者的浏览器以已登录身份请求你的接口,因为服务端回显了攻击者的 Origin 并允许凭据,浏览器就把带 Cookie 的响应交给攻击者的 JS 了。攻击者于是能读到受害者的个人信息、订单列表、后台数据,甚至拿到 CSRF token 后继续发起写操作。这条链叫 CORS 配置错误导致的跨站数据窃取,在实战中很常见,而且它不需要受害者做任何事,只要他曾经登录过你的站并且访问了攻击者的页面。
正确的写法是白名单比对,并且要小心字符串匹配这件事:
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allow = [
'https://www.example.com',
'https://m.example.com',
];
if (in_array($origin, $allow, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin');
}
这里 in_array 的第三个参数 true 不能省,否则是松散比较。比数组更糟的写法是用 strpos 或正则做"包含"匹配:strpos($origin, 'example.com') !== false 会被 https://evil-example.com、https://example.com.attacker.net 直接绕过。还有人写 preg_match('/example\.com$/', $origin),看着更严了,但仍会放行 https://notexample.com(如果正则没带前面的分隔),或者漏掉端口差异。
判据很简单:Origin 的匹配必须是完整字符串相等,不能是前缀、后缀、包含或正则的宽松匹配。需要允许任意子域时,把 Origin 解析成 parse_url() 的 host 部分,再判断是否以 .example.com 结尾并且协议是 https,同时拒绝 example.com.evil.net 这类后缀伪装。
四、CDN 与反向代理下的 CORS 陷阱
自建站很少是"浏览器直连 PHP",中间通常有 Nginx、CDN、甚至一层负载均衡。而 CORS 头在这些层里很容易被处理两次或者被缓存错。
Nginx 里同时配了 add_header 和 PHP 里也 header()
两边都写就是两个同名的响应头,浏览器看到 Access-Control-Allow-Origin: https://a.com 和 ...: https://b.com 两行,会直接判定无效并拒绝。要么全部在 PHP 里出,要么全部在 Nginx 里出。推荐的做法是"接口类由 PHP 出(因为它需要看 Origin 做白名单),静态资源类由 Nginx 出"。
add_header 的继承陷阱
Nginx 的 add_header 有一个著名行为:只要当前 location 里出现了任何一条 add_header,上一层的 add_header 就全部失效(除非加 always 参数)。所以你在 server 块里配好了 CORS 三个头,某个 location 里又加了一条 add_header Cache-Control,那个 location 的 CORS 头就悄悄没了。现象是"首页能跨域,某个接口不行",很难查。
# 静态资源:允许任意源读取(无凭据)
location ~* \.(woff2|ttf|otf|css|js)$ {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Max-Age 86400 always;
}
字体文件是必须配 CORS 的典型例子。浏览器对 @font-face 加载的字体有强制同源要求,不配 Access-Control-Allow-Origin 就会在控制台报 "blocked by CORS policy",页面字体回落到系统字体。很多站长配了 CDN 之后发现字体不对,原因就在这里。
CDN 缓存了带 Origin 的响应
这是最麻烦的一种。CDN 默认按 URL 缓存,不区分 Origin 头。如果你的接口对 A 源返回了 Allow-Origin: https://a.com,这个响应被缓存,B 源来请求时拿到的还是 A 的那份,于是 B 被浏览器拒绝;反过来更糟——如果 CDN 缓存的是 * 并且你的接口带凭据,那么任何人都能读到。
解决办法有三条,选一条即可:第一,加 Vary: Origin 让 CDN 按 Origin 分键缓存(多数 CDN 支持,但会降低命中率);第二,接口响应一律 Cache-Control: no-store,不进入 CDN 缓存;第三,在 CDN 配置里把接口路径的缓存规则设为"不缓存"。个人站长最省事的是第二条,接口本来就不该被缓存。
五、预检请求为什么总是失败
预检失败的排查有固定顺序,因为浏览器的报错信息往往只说"CORS 错误",不说具体哪一步没过。按下面四条挨个看:
- OPTIONS 返回了 405 或 404。很多框架默认不给 OPTIONS 路由,或者 PHP-FPM 前面的 Nginx 把非 GET/POST 直接拒了。在 Nginx 里加
if ($request_method = OPTIONS) { add_header ... ; return 204; },或者确保应用层处理 OPTIONS 并返回 204。 - Allow-Headers 漏了某个头。前端发了
X-Requested-With或Authorization,你只回了Content-Type,预检就过不了。最简单的调试方式是用 curl 手动发一遍 OPTIONS,看响应头缺什么。 - Allow-Methods 没包含实际方法。常见于只写了
GET, POST,而前端用了 PUT 做更新。 - 预检通过了但真请求失败。这时问题不在 CORS,而在真请求返回了 500 或者另一个 CORS 头。分别 curl 一下 OPTIONS 和真请求,比对两者的响应头,差异一眼可见。
手动预检的命令是这样,注意必须带 Origin 与 Access-Control-Request-Method 两个头,缺一个就得不到预检响应:
curl -i -X OPTIONS "https://api.example.com/v1/items" \
-H "Origin: https://www.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type,authorization"
返回 204 且带着三个 Allow 头,说明预检这一关是通的。如果返回 200 但没有任何 CORS 头,就是应用层没处理 OPTIONS(框架把 OPTIONS 当成了普通请求但没走中间件)。
六、分场景的推荐配置
不同用途的接口对 CORS 的要求完全不同,别用一套配置打天下:
公开只读 API(如给前端站点取数据的列表接口)
可以直接 Allow-Origin: *,但绝不能带凭据。这类接口用 API Key 放在 query 或 header 里做认证,而不是靠 Cookie。这样即使任意站点调用,也拿不到别人的数据。
需要 Cookie 的私有接口(后台、用户中心)
必须用白名单且回显单个源,配 Allow-Credentials: true,同时把 Cookie 设成 SameSite=Lax(同站子域互通时用 SameSite=None; Secure,但那要求 https 且会削弱 CSRF 防护)。这种情况下前端 axios 要设 withCredentials: true,fetch 要设 credentials: 'include',否则 Cookie 不带。
静态资源(字体、JS、CSS、图片)
* 就够,不需要凭据,配上 Max-Age: 86400 减少预检。图片还有额外的坑:<img>、<video> 标签本身不受 CORS 限制(能显示),但如果要用 canvas.drawImage() 处理它,图片就必须带 CORS 头,否则 canvas 会被"污染",toDataURL() 直接抛异常。做图片合成、加水印的前端功能时这是必踩的坑。
子域之间的调用(www 调 api、m 调 www)
子域算跨域,必须配 CORS。但因为同属一个主域,可以配 document.domain 的替代方案——现代浏览器已经在弃用它,建议直接走 CORS 白名单。同时别忘了把 Cookie 的 Domain=.example.com 设上,让子域共享。
七、把 CORS 当成"默认拒绝"的白名单系统
讲了这么多具体的头,最后落到一条设计原则上:CORS 应该是一个默认拒绝的白名单系统,而不是默认允许的黑名单。
具体落实成三条:
- 白名单写进配置文件,不要散落在各个接口文件里。这样能一眼看出全站对外开放了哪些源,也方便发版时审查。
- 不在白名单里的 Origin,不下发任何 CORS 头,而不是下发一个错误的头。浏览器看不到 Allow-Origin 自然就拦住了,日志里也能看出"有人在试探"。
- 每次新增一个允许的源,都要问一句:这个源被攻破之后会泄露什么?如果答案是"能看到全站用户的订单",那这个源就不该进白名单。
另外,Origin: null 一定要拒绝。它来自沙箱 iframe、本地 file:// 页面、以及某些重定向场景,任何攻击者都能构造出 null。如果你的白名单里出现了 null,那等于对外完全开放。
八、一句话验收
配完不想读代码,用两条 curl 就能判定凶吉。第一条,伪造一个恶意的 Origin,看服务端是否回显:
curl -i "https://api.example.com/v1/me" -H "Origin: https://evil.example.net" | grep -i access-control
理想结果是没有任何输出(一个 access-control 头都没有)。如果看到了 Allow-Origin: https://evil.example.net 并且还有 Allow-Credentials: true,那就是严重漏洞,必须立刻修。
第二条,确认自己的正式源正常工作、预检通过、Vary 头在。三条都对,配置才算完成。CORS 是一个"配错不会报错、只在特定浏览器+特定凭据组合下才炸"的东西,所以一定要用 curl 主动验,别等出事了才发现。