一次 405 引发的跨域血案:XHR 预检请求与 Nginx 站点配置的完整排坑
前端带 Cookie 调上传接口,Chrome 报
CORS Missing Allow Origin,Postman 一切正常。查了两小时,发现罪魁祸首是 Nginx 里一行不起眼的add_header。
一、问题现场
前端页面在 w.haven.chat,接口在 wmliao.haven.chat,典型的跨子域请求。上传接口用的是 multipart/form-data,前端用 jQuery 的 $.ajax 发出,配置了:
$.ajaxSetup({
xhrFields: { withCredentials: true }
});
浏览器报错:
text
已拦截跨源请求:同源策略禁止读取位于 https://xxxx/?s=api/Upload/uploadNewChuck 的远程资源。
(原因:凭据不支持,如果 CORS 头 'Access-Control-Allow-Origin' 为 '*')。
奇怪的是,同一个域下的登录接口 checklogin 却完全正常。为什么一个报错、一个不报?
二、为什么 checklogin 没事、upload 报错
关键差异在"简单请求"和"非简单请求"。
浏览器的跨域请求分两种:
类型 触发条件 是否先发 OPTIONS 预检
简单请求 方法为 GET/HEAD/POST,且 Content-Type 为 text/plain、application/x-www-form-urlencoded、multipart/form-data,且无自定义头 不发
非简单请求 其他情况(比如带 application/json、自定义 header 等) 必须先发 OPTIONS
checklogin 是 fetch(url, { method: 'POST', credentials: 'include' }),空 body,没有 Content-Type,属于简单请求——直接发 POST,走完整业务逻辑。
uploadNewChuck 带 multipart/form-data,且上传场景经常同时带上 X-Requested-With: XMLHttpRequest 之类的自定义头,这让请求变成"非简单请求",必须先发 OPTIONS 预检。
而这个预检请求,就是整个问题的核心。
三、第一个坑:Nginx 抢了 OPTIONS 的处理权
大多数 Nginx 站点配置里,都有一段"贴心地帮你处理 CORS"的代码:
nginx
location / {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PATCH, DELETE, PUT, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,...';
add_header 'Access-Control-Max-Age' 1728000;
return 204;
}
if (!-e $request_filename) {
rewrite ^(.*)$ /index.php?s=$1 last;
}
}
看起来很美,但对带 Cookie 的跨域请求是致命的。
3.1 错在 * 与 withCredentials 不能共存
只要 withCredentials: true,浏览器的 CORS 规则就变成:
Access-Control-Allow-Origin 必须是具体域名,不能是 *
必须同时返回 Access-Control-Allow-Credentials: true
Nginx 无条件发 *,浏览器直接把响应判为非法,报 CORS Missing Allow Origin。
这里有个隐蔽点:即使 Nginx 那段也带了 Access-Control-Allow-Credentials: true,Access-Control-Allow-Origin 是 * 也照样不行。这俩必须配对。
3.2 错在 OPTIONS 被 Nginx 拦截后 PHP 拿不到
return 204 意味着请求到此为止。PHP-FPM 根本没收到这次 OPTIONS 请求,后端精心写的 CORS 逻辑完全没机会执行。
四、第二个坑:删了 Nginx 的 CORS 后变成 405
我第一反应是:既然 Nginx 乱来,那把 CORS 相关全删掉,交给后端处理。
删完,再测:
text
HTTP/1.1 405 Not Allowed
Server: nginx
Content-Type: text/html
Nginx 直接 405 了。
原因:OPTIONS 方法在 Nginx 的"静态文件服务"上下文里默认不被允许。请求到了 location / 后,因为 $request_filename 存在(/ 是个目录),跳过了 rewrite,Nginx 尝试当静态请求处理,方法不允许,返回 405。
也就是说,删掉 Nginx 的 CORS 后,OPTIONS 请求压根到不了 PHP。 这一步很多人会踩——以为"全删了让后端处理"就完事,实际上还要给 OPTIONS 请求开一条通路到 PHP。
五、正确方案:三层协同
核心原则:CORS 头只能由一层输出,OPTIONS 必须交给后端处理。
5.1 Nginx:只负责转发,不负责 CORS
nginx
location / {
# 关键:让 OPTIONS 请求进 PHP
if ($request_method = OPTIONS) {
rewrite ^(.*)$ /index.php last;
break;
}
if (!-e $request_filename) {
rewrite ^(.*)$ /index.php?s=$1 last;
break;
}
}
要点:
删掉所有 add_header 'Access-Control-Allow-*'
删掉 if ($request_method = 'OPTIONS') { return 204; } 块
只加一条 OPTIONS 强制走 index.php
用 last 而非 break,让 Nginx 重新匹配 location