一次 405 引发的跨域血案:XHR 预检请求与 Nginx 站点配置的完整排坑

一次 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
此条目发表在js, php分类目录。将固定链接加入收藏夹。

发表回复