
这个问题的标题里有一个关键词——“误判”。在绝大多数CORS报错的排查复盘里大家习惯把锅甩给浏览器觉得它又在发神经。但实际经历过Sa-Token过滤器引发的CORS误判之后我才意识到浏览器这个“判官”其实一丝不苟真正出问题的是后端过滤器链路的执行顺序。先说说那个下午的现场前端同事突然在群里喊“所有带Token的请求全部挂了控制台全是CORS报错”。我第一反应是网关配置被人动过或者Nginx跨域头被改了。结果跑到浏览器里一看Network面板里整整齐齐排着一列OPTIONS预检请求后面跟着一个个401。没有Access-Control-Allow-Origin全是CORS policy报错。那一刻我确认这锅大概率不在Nginx不在前端就在我们后端某个过滤器身上。1. 先复现一次“OPTIONS被拦死”的现场1.1 浏览器控制台里所有接口集体阵亡具体现象是这样的这是一个典型的前后端分离项目前端跑在http://front.example.com后端接口在http://api.example.com。前端所有登录后的接口请求浏览器都会先发一个OPTIONS预检请求然后才发真正的POST或GET请求。报错长得非常经典Access to XMLHttpRequest at http://api.example.com/api/user/me from origin http://front.example.com has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.打开Network面板能看到两列请求第一列是OPTIONS请求Method是OPTIONS状态码是401。第二列是POST或GET状态码可能是200但Response Headers区域一片空白没有CORS头浏览器直接拦下来不让JS读取。这里有一个非常迷惑人的点如果你不点开看会以为所有请求都失败了但点开后你会发现后端其实“响应”了返回的是一段JSON里面写着{code:401,msg:未登录}。也就是说接口本身有返回浏览器却因为跨域名策略愣是不把这段返回交给前端JS读取。1.2 为什么401/403会显示成CORS报错很多刚接触跨域问题的同学会有个误区CORS报错就一定是跨域配置不对或者一定是Nginx少了Access-Control-Allow-Origin。实际上浏览器检查CORS响应头是有先后的浏览器判断请求是否跨域如果是则执行CORS策略。浏览器先看响应头里有没有Access-Control-Allow-Origin且它是否包含当前页面的Origin。如果没有直接判定为CORS失败不看响应状态码也不管响应体里是什么。如果头都在且匹配浏览器才会把响应交给JS业务逻辑去处理哪怕状态码是401前端也能正常读到。所以后端返回401这个事实在浏览器眼里根本不重要你响应头里没有CORS允许头那这次跨域请求就是失败的。前端看到的不是“未登录”而是“CORS错误”这就是标题里说的“误判”的表象。真正的元凶其实是Sa-Token过滤器把OPTIONS预检请求拍死在了CORS头写入之前。2. Sa-Token过滤器在CORS链路里究竟改变了什么2.1 预检请求的完整生命周期先花30秒把预检机制的细节捋一遍。浏览器对跨域请求的处理分为两类简单请求指的是GET、HEAD、POST且Content-Type只允许application/x-www-form-urlencoded、multipart/form-data、text/plain的请求。简单请求不会触发预检但响应时仍然需要Access-Control-Allow-Origin头。非简单请求只要带了自定义头比如Authorization、X-Token或者Content-Type是application/json浏览器都会先发送一个OPTIONS请求询问后端“我这边是front.example.com想发一个带content-type,authorization请求头的POST请求你允不允许”这个OPTIONS请求一般长这样OPTIONS /api/user/me HTTP/1.1 Host: api.example.com Origin: http://front.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type,authorization后端如果允许就在响应头里返回这样一串Access-Control-Allow-Origin: http://front.example.com Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS Access-Control-Allow-Headers: content-type,authorization Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600浏览器拿到这些头之后才会真正发出POST请求。整个过程中浏览器对OPTIONS请求的响应有一个硬性要求响应必须包含上面那些CORS允许头且状态码必须是2xx系列。一旦状态码是401或者缺了某个头预检直接失败真实POST请求根本不会发出。2.2 SaServletFilter的默认鉴权动作Sa-Token作为Java生态里常用的轻量级权限认证框架在Spring Boot项目中经常会注册一个全局过滤器写法一般是这样的Bean public SaServletFilter saServletFilter() { return new SaServletFilter() .addInclude(/**) .addExclude(/api/user/login, /api/user/register, /error) .setAuth(r - SaRouter.match(/**).checkLogin()) .setError(e - SaResult.error(未登录请先登录)); }这段代码的意图很好理解除了登录、注册这两个入口其余所有路径都要校验用户是否已经登录也就是检查请求里有没有合法Token。问题在于它校验的是“所有请求”而预检请求OPTIONS也算请求。浏览器发出的OPTIONS预检通常不会携带Authorization头也不会带业务TokenSa-Token过滤器一旦走到checkLogin()这里就会直接判定未登录抛出异常紧接着进入setError()返回一个401响应。这个401响应体是一段JSON但关键问题是响应头里没有Access-Control-Allow-Origin、没有Access-Control-Allow-Methods、没有Access-Control-Allow-Headers。2.3 根因过滤器顺序与响应头写入时机这里才是问题的核心。CORS响应头不是凭空出现的它必须有某个组件在某个时机写进响应里。而这个时机非常讲究。Spring Boot中的Filter是在Servlet容器层面执行的多个Filter形成一条Filter Chain请求依次经过这些Filter最后才进入DispatcherServlet。我们通常配置跨域的方式有两种方式一注册一个Spring的CorsFilter它是一个Servlet Filter通过FilterRegistrationBean控制执行顺序。方式二实现WebMvcConfigurer接口重写addCorsMappings()方法这种方式本质上是Spring MVC在HandlerMapping层面处理CORS也就是说它得等到请求进入DispatcherServlet之后才介入才能写响应头。问题来了SaServletFilter同样是一个Servlet Filter而且它大概率会被Spring Boot自动注册到Filter Chain里。如果你没有显式给它设置order它的执行顺序就成了“薛定谔的顺序”——取决于Spring Boot对Bean的命名排序、注册顺序等等。当SaServletFilter恰好排在CorsFilter前面或者项目里根本没有CorsFilter只有addCorsMappings()配置时OPTIONS请求一进入Filter Chain就会先撞上SaServletFilter然后SaServletFilter发现没有Token直接返回401。Filter Chain中断后面的CorsFilter、DispatcherServlet统统没有机会执行。CorsFilter的代码不会执行addCorsMappings()配置的HandlerMapping也接触不到请求。于是响应头里没有CORS信息浏览器收到401响应一看没CORS头直接报错。如果项目里用的是addCorsMappings()情况更隐蔽你在代码里明明配置了allowedOrigins、allowedMethods但请求连DispatcherServlet都没进这个配置自然永远不生效。2.4 两种“原本能正常CORS”却被打断的典型项目结构我把常见的项目结构拉出来对照了一下见下表项目结构CORS处理组件在哪个环节SaServletFilter拦截对CORS的影响已有CorsFilter且顺序在SaServletFilter之前Filter Chain中先于认证拦截无影响OPTIONS被CorsFilter短路处理已有CorsFilter但顺序在SaServletFilter之后Filter Chain中后于认证拦截有影响请求在CorsFilter前被401截断只有WebMvcConfigurer的addCorsMappings()DispatcherServlet内部的HandlerMapping有影响请求根本到不了DispatcherServlet后端无任何CORS配置无一直报错引入Sa-Token只是让报错更典型所以标题里说的“误判”真正指的并不是浏览器判断错了而是后端认证过滤器抢跑直接把CORS处理流程给绕过了。浏览器是严格按照规则办事的它没有看到允许头就认为这个跨域请求不被允许这其实不算误判顶多算“被逼无奈”。3. 排查实录不猜用两步实验锁定真凶3.1 用curl绕过浏览器直接打OPTIONS浏览器开发者工具能看现象但不利于快速复现。我当时的做法是先抓包看请求然后直接开一个终端用curl手动构造一个OPTIONS请求完全模拟浏览器的预检行为curl -i -X OPTIONS http://127.0.0.1:8080/api/user/me \ -H Origin: http://front.example.com \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: content-type,authorization响应结果是这样的HTTP/1.1 401 Content-Type: application/json;charsetUTF-8 {code:401,msg:未登录请先登录}注意看响应头我特意把整段响应翻来覆去看了三遍确认没有任何一个Access-Control-Allow-*头。而且状态码直接是401不是正常的CORS预检响应应该有的200或204。这里有个很值得说的点用curl请求时curl不会校验CORS头只会原样打印响应。所以我们可以非常直观地看到后端确实在401之前就早早给请求判了“死刑”根本没有走到任何写CORS头的组件。3.2 临时排除路径做A/B对比锁定方向之后我需要确认这个401一定是SaServletFilter干的。最直接的办法是做一个A/B对比实验把测试接口临时加到SaServletFilter的addExclude排除列表里。.addExclude(/api/user/me, /api/user/login, /api/user/register)改完配置重新启动服务再打一次刚才的curl命令curl -i -X OPTIONS http://127.0.0.1:8080/api/user/me \ -H Origin: http://front.example.com \ -H Access-Control-Request-Method: POST如果响应变成了200并且响应头里出现了Access-Control-Allow-Origin说明这个接口从Sa-Token过滤器的拦截范围中移除后后续的CORS处理组件有机会正常工作了。实验结果也确实如此——响应头里出现了Access-Control-Allow-Origin说明问题就出在SaServletFilter对OPTIONS请求的拦截上。注意这个A/B实验只是为了验证判断绝对不能把/api/user/me长期加到排除列表里否则这个接口就失去了登录校验安全边界直接被击穿。3.3 日志里看一眼Sa-Token抛出的异常还有一种更快速的坐实方式就是在SaServletFilter的setError回调里临时加一行日志把被拒绝的请求方法打出来.setError(e - { System.out.println(Sa-Token reject, method SaHolder.getRequest().getMethod() , uri SaHolder.getRequest().getRequestURI()); return SaResult.error(未登录请先登录); })重新启动项目刷新一下前端页面后端控制台立刻刷出一排日志Sa-Token reject, methodOPTIONS, uri/api/user/me Sa-Token reject, methodOPTIONS, uri/api/user/me这一行日志等于把整个问题的推理闭环了OPTIONS请求确实是被Sa-Token的Filter给拦下来的而不是被Nginx、不是被网关、也不是被什么神秘的中间件。到这里我已经不需要再去翻什么框架源码了。问题的因果关系非常清晰浏览器发预检 - SaServletFilter拦截 - 返回401 - 响应头缺失CORS信息 - 浏览器判定跨域失败。4. 落地修复我更偏向CorsFilter前置附对比4.1 方案一在Sa-Token内部放行OPTIONS网上很多帖子给出的方案是在SaServletFilter的setBeforeAuth回调里判断如果请求方法是OPTIONS就调用SaRouter.back()跳出路由匹配不执行后续鉴权。代码是这样的Bean public SaServletFilter saServletFilter() { return new SaServletFilter() .addInclude(/**) .addExclude(/api/user/login, /api/user/register, /error) .setBeforeAuth(r - { if (OPTIONS.equals(SaHolder.getRequest().getMethod())) { SaRouter.back(); } }) .setAuth(r - SaRouter.match(/**).checkLogin()) .setError(e - SaResult.error(未登录请先登录)); }这个方案本身没问题它确实能把OPTIONS请求从Sa-Token的鉴权逻辑中放出去请求会继续向后走最终到达CorsFilter或HandlerMapping由它们补上CORS响应头。但它有一个前提你的项目里必须有一个能“接住”OPTIONS请求并写入CORS头的后续组件。如果项目里只有addCorsMappings()而这个请求又确实进入了DispatcherServlet那么addCorsMappings是能生效的。如果你什么都没配那OPTIONS请求虽然不再401了但响应头里依然没有Access-Control-Allow-Origin浏览器同样报错。所以单独用方案一只解决了“认证过滤器提前拦截”的问题没有解决“谁写CORS头”的问题。它适合作为临时应急改动而不是一个永久靠谱的架构选择。4.2 方案二把CorsFilter注册到最前我最终选择的是方案二显式注册一个Spring官方提供的CorsFilter并把它放在Filter Chain的最前面。Bean public FilterRegistrationBeanCorsFilter corsFilterRegistrationBean() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(Arrays.asList( http://front.example.com, http://admin.example.com )); config.setAllowedMethods(Arrays.asList( GET, POST, PUT, DELETE, PATCH, OPTIONS )); config.setAllowedHeaders(Collections.singletonList(*)); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); FilterRegistrationBeanCorsFilter bean new FilterRegistrationBean(new CorsFilter(source)); bean.setOrder(Ordered.HIGHEST_PRECEDENCE); return bean; }这里的关键点是bean.setOrder(Ordered.HIGHEST_PRECEDENCE)。它让CorsFilter成为整个Filter Chain最外层最先执行的那个Filter。执行链路变成了这样OPTIONS预检请求进来CorsFilter直接接手根据配置判断当前Origin是否在白名单内。如果在白名单内CorsFilter会把CORS响应头写进响应然后立刻短路返回根本不会让请求继续往下走到SaServletFilter。真实POST请求进来CorsFilter同样先写好CORS响应头然后再把请求放行给SaServletFilter认证。即使认证失败返回401由于CORS头已经写好了浏览器能够正常读取401响应前端拿到真实错误码而不是一团CORS迷雾。这个方案解决的不只是预检问题它还解决了另一个隐蔽问题当Sa-Token正常返回401时响应头里有没有CORS头。用方案二之后无论接口返回什么状态码响应都会先经过CorsFilterCORS头永远会在这是方案一很难做到的一致性保障。4.3 方案对比与安全边界我把几个可能的选择连同实现成本放在一起做了个比较方案实现成本能否根治安全隐患推荐度方案一SaServletFilter内放行OPTIONS低加几行判断依赖后续CORS组件存在否则无效需小心不要误伤真正的OPTIONS业务请求临时应急可用方案二CorsFilter置顶中多写一个配置Bean能从根本上修复需运维白名单避免允许来源过宽强烈推荐手写一个CORS相关Filter中高需要自己处理所有Header能容易重复造轮子漏写某个头又是新坑不推荐把OPTIONS请求加入SaServletFilter的addExclude最低改路径列表不能addExclude只认路径不认Method会把对应路径的真实请求也放行非常不推荐这里特别提一句“把OPTIONS加入addExclude”的伪方案。SaServletFilter.addExclude()接收的是路径表达式它无法按HTTP方法区分。你想排除“OPTIONS方法”只能靠路径但这意味着该路径下的GET、POST、PUT等所有请求也会被排除认证。这不是修CORS这是在给安全开洞。方案一的另一个隐患在于如果你选择在Sa-Token过滤器内部手动给OPTIONS响应补CORS头比如判断到OPTIONS就执行SaHolder.getResponse().setHeader(Access-Control-Allow-Origin, SaHolder.getRequest().getHeader(Origin));这种写法有一个非常危险的安全陷阱盲目回显请求头里的Origin。如果公网用户访问你的后端他在请求头里伪造一个Origin你的响应就会把这个Origin原样回显等于告诉浏览器“我允许任何来源跨域访问”。这已经不是修Bug而是制造了一个CORS配置漏洞。所以我更推荐用白名单机制管理跨域来源而不是无条件反射Origin。5. 修复后的验证与后续防线5.1 回归验证清单修复完成后我给自己定了一个固定回归清单每次配置CORS或调整过滤器顺序都要跑一遍第一步用curl打OPTIONS预检期望返回200或204且响应头包含以下三个头curl -i -X OPTIONS http://127.0.0.1:8080/api/user/me \ -H Origin: http://front.example.com \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: content-type,authorization期望响应头如下Access-Control-Allow-Origin: http://front.example.com Access-Control-Allow-Methods: GET,POST,PUT,DELETE,PATCH,OPTIONS Access-Control-Allow-Headers: content-type,authorization Access-Control-Allow-Credentials: true第二步打开浏览器手动触发一个非简单请求确认控制台不再有CORS报错。第三步用一个未登录的身份去调用受保护接口确认前端能正常收到401业务响应也就是响应头中仍然有CORS头。第四步用Postman这类不限跨域的客户端测试一遍登录流程确保业务功能正常。这一套跑完问题才算真正收尾。5.2 高版本Spring与allowedOriginPatterns在配置CorsFilter时很多旧项目还在用setAllowedOrigins(Arrays.asList(*))。但如果你的CorsConfiguration同时设置了setAllowCredentials(true)Spring的高版本会直接拒绝这种组合因为“允许所有来源”加上“允许携带Cookie”是不能同时成立的。Spring Framework 5.3之后提供了一个新APIsetAllowedOriginPatterns。它支持用*指定一组来源模式同时允许搭配allowCredentials(true)使用比如config.setAllowedOriginPatterns(Arrays.asList(http://*.example.com, https://*.example.com)); config.setAllowCredentials(true);实际项目中我更建议把跨域白名单从配置中心或application.yml里读取而不是硬编码在Java代码里。比如这样custom: cors: allowed-origins: - http://front.example.com - http://admin.example.com然后在配置类里注入并转换成数组。这样后续新增前端域名时只需改配置不需要再改代码重新发版。5.3 前后端分离项目里的CORS治理建议经历过这次Sa-Token过滤器导致的CORS误判之后我对CORS治理有了几个很朴素的认识第一CORS配置的关注点不只是“有没有配置”而是“这个配置在请求链路中的哪一层生效”。如果你的项目里有多个Filter都会提前终止请求那么写在后面的CORS配置可能永远没机会执行。新增加认证Filter、网关Filter、日志Filter时都要顺手过一遍过滤器顺序。第二网关或Nginx层统一处理CORS是一个不错的选择。假如你的项目前端域名统一走Nginx转发可以在Nginx层统一添加Access-Control-Allow-*头后端就不需要关心CORS。但即便如此后端也要保留一个能应对开发环境、本地直连的CORS兜底方案否则开发者本地调试时又会被问题绊住。第三认证过滤器和CORS处理器最好不要搅在一起。Sa-Token负责登录校验CorsFilter负责跨域策略两者职责分离各管一段。我在修复时也看到过有同学试图在SaServletFilter的setError里手动加CORS头这样确实能解决一部分问题但会随着后续接口增加、异常类型增多而变得难以维护。与其修修补补不如把CorsFilter以最高优先级注册到最外层。最后分享一个小习惯我后来在项目启动时会把当前注册的FilterRegistrationBean顺序打印出来确认CorsFilter确实是第一个。Spring Boot启动日志里也可以看到FilterChain的注册顺序。每次升级Spring Boot或Sa-Token版本后我都会重新看一眼这个顺序因为版本升级可能带来默认Filter兜底顺序的变化而这种变化最容易在不经意间制造新的CORS问题。先看顺序再谈兼容这个习惯帮我省掉了未来很多个“灵异”下午。