前后端分离之后,CORS 报错缠了我整整一个下午
朋友让我帮忙看一个后台管理页面,说他本地跑得好好的,部署到服务器上之后,登录接口一直报错。 控制台里的红字是这样的: 1 2 3 4 Access to XMLHttpRequest at 'https://api.example.com/auth/login' from origin 'https://admin.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. 典型的 CORS。我当时心想,加个头的事,十分钟搞定。 结果从下午两点弄到六点。 先说清楚 CORS 是什么 跨域资源共享,是浏览器的一套安全机制。 浏览器有个同源策略。两个 URL,协议、域名、端口三者完全一样才算同源。https://a.com 和 https://api.a.com 不同源,https://a.com 和 http://a.com 也不同源,端口不同更不用说。 不同源之间的请求,浏览器默认会拦。但这个拦分两种。 简单请求,浏览器直接发出去,服务器返回了,浏览器检查响应头里有没有 Access-Control-Allow-Origin,没有就把结果丢掉,报错。注意,请求是真的发出去了,服务器也真的处理了,只是浏览器不把结果给页面。 非简单请求,浏览器会先发一个 OPTIONS 请求,叫预检。服务器要在这个预检的响应里明确告诉浏览器,允许哪些方法、哪些头。预检通过了,浏览器才发真正的请求。 什么算非简单请求。方法不是 GET、HEAD、POST 的;Content-Type 不是 application/x-www-form-urlencoded、multipart/form-data、text/plain 的;带了自定义请求头的。这三条占一条就算。 我的登录接口用的是 POST 加 Content-Type: application/json,所以是非简单请求,会触发预检。 先看看预检返回了什么 用 curl 模拟一下: 1 2 3 4 curl -i -X OPTIONS https://api.example.com/auth/login \ -H "Origin: https://admin.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: content-type" 返回: 1 2 3 4 HTTP/2 405 server: nginx/1.24.0 content-type: text/html allow: POST 405 Method Not Allowed。也就是说,Nginx 把 OPTIONS 请求转给了后端,后端不认识 OPTIONS 这个方法,直接拒绝了。 问题找到了。后端框架没有处理预检请求。 让后端来处理 如果后端是 Spring Boot,加一个全局配置: 1 2 3 4 5 6 7 8 9 10 11 12 @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("https://admin.example.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } } allowCredentials(true) 是为了让浏览器带上 cookie。这个后面会讲到,它有个限制。 如果是 Express: 1 2 3 4 5 6 7 const cors = require('cors'); app.use(cors({ origin: 'https://admin.example.com', credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], maxAge: 3600 })); 这个是正路。但有个前提,后端得能改。我这个朋友的后端是外包做的,源码不在手上,只能从 Nginx 那边想办法。 在 Nginx 里拦下来 Nginx 的做法是,遇到 OPTIONS 请求,直接返回,不往后端转。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 location /auth/ { if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' 'https://admin.example.com' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always; add_header 'Access-Control-Max-Age' 3600 always; add_header 'Content-Length' 0; return 204; } add_header 'Access-Control-Allow-Origin' 'https://admin.example.com' always; add_header 'Access-Control-Allow-Credentials' 'true' always; proxy_pass http://backend; } 几个细节。 return 204 是 No Content,预检请求不需要返回内容,204 是标准做法。有些教程写 200,也能过,但 204 更准确。 always 这个参数很重要。不加它,add_header 只在响应码是 200、201、204、206、301、302、303、304、307、308 的时候才生效。如果后端返回 400 或者 500,头就没了,浏览器那边会报 CORS 错误,把真正的问题盖住。 Content-Length 0 是为了让某些严格的客户端不等待响应体。 if 在 location 里用是有风险的,官方文档里叫 “if is evil”。但这个场景是安全的,因为它只是判断一个变量然后 return,不涉及 proxy_pass 的重新求值。这是官方文档里明确认可的一种用法。 为什么不能写星号 很多人图省事,直接写: 1 add_header 'Access-Control-Allow-Origin' '*'; 这个在不需要 cookie 的场景下是可以的。但只要你的前端带上了 credentials: 'include',或者 withCredentials = true,浏览器就会拒绝。 因为规范里写得很清楚,Access-Control-Allow-Origin 是 * 的时候,Access-Control-Allow-Credentials 不能是 true。 原因不难理解。如果允许任意源加凭证,那任何网站都能带着用户的 cookie 请求你的接口,这就是 CSRF。 所以我用了具体的域名,而不是星号。 多个域名怎么办 如果前端有好几个域名,比如正式环境和测试环境,就得动态判断。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 map $http_origin $cors_origin { default ""; "~^https://admin\.example\.com$" $http_origin; "~^https://staging\.example\.com$" $http_origin; "~^http://localhost:5173$" $http_origin; } server { location /api/ { add_header 'Access-Control-Allow-Origin' $cors_origin always; add_header 'Access-Control-Allow-Credentials' 'true' always; add_header 'Vary' 'Origin' always; ... } } map 里的正则要转义点号,不然 example.com 会匹配到 exampleXcom。 add_header Vary Origin 这一行别漏。它告诉缓存层,同一个 URL 对不同 Origin 的响应可能不一样。没有它,CDN 或者浏览器可能把 A 域名的响应缓存下来给 B 域名用,导致时好时坏。 一个特别隐蔽的问题 配完之后,登录接口通了,但另一个接口还是报错。 1 2 The 'Access-Control-Allow-Origin' header contains multiple values 'https://admin.example.com, https://admin.example.com', but only one is allowed. 头出现了两次。 原因是,Nginx 加了,后端也加了。两边的值一样,但浏览器只接受一个。 排查方法: 1 curl -sI https://api.example.com/some/endpoint -H "Origin: https://admin.example.com" | grep -i access-control 能看到重复的行。 解决办法是二选一。要么让后端别加,要么在 Nginx 里用 proxy_hide_header 把后端的那个藏起来: 1 2 proxy_hide_header 'Access-Control-Allow-Origin'; add_header 'Access-Control-Allow-Origin' $cors_origin always; 这个坑我踩了很久,因为报错信息里说的是 “multiple values”,但它不会告诉你这两个值是从哪儿来的。 Cookie 相关的额外配置 如果前后端不同域,cookie 的 SameSite 属性会挡住。 从 Chrome 80 开始,SameSite 默认是 Lax,意思是跨站请求不带 cookie。前后端不同域,就是跨站。 解决办法是把 cookie 设成 SameSite=None; Secure。 None 必须配 Secure,也就是说必须走 https。http 环境下浏览器会直接拒绝这个 cookie。 如果是后端设的 cookie,得改后端的配置。如果是 Nginx 设的,可以这样: 1 proxy_cookie_flags ~ secure samesite=none; 这个指令从 Nginx 1.19.3 开始有。 预检缓存 每次跨域请求之前都要发一个 OPTIONS,这个开销不小。 Access-Control-Max-Age 就是用来缓存的,单位是秒。 1 add_header 'Access-Control-Max-Age'
以上内容来自博客订阅源,版权归原作者。关注作者后可以在 关注列表 第一时间查看作者文章。

登录后,加入这场讨论。
登录参与