ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Sa-Token过滤器引发CORS误判:OPTIONS预检被拦截的排查与修复

Sa-Token过滤器引发CORS误判:OPTIONS预检被拦截的排查与修复

这个问题的标题里有一个关键词——“误判”。在绝大多数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响应头是有先后的:

  1. 浏览器判断请求是否跨域,如果是,则执行CORS策略。
  2. 浏览器先看响应头里有没有Access-Control-Allow-Origin,且它是否包含当前页面的Origin。
  3. 如果没有,直接判定为CORS失败,不看响应状态码,也不管响应体里是什么。
  4. 如果头都在且匹配,浏览器才会把响应交给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头,也不会带业务Token,Sa-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;charset=UTF-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, method=OPTIONS, uri=/api/user/me Sa-Token reject, method=OPTIONS, 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 FilterRegistrationBean<CorsFilter> 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); FilterRegistrationBean<CorsFilter> 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头。用方案二之后,无论接口返回什么状态码,响应都会先经过CorsFilter,CORS头永远会在,这是方案一很难做到的一致性保障。

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之后提供了一个新API:setAllowedOriginPatterns。它支持用*指定一组来源模式,同时允许搭配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问题。先看顺序,再谈兼容,这个习惯帮我省掉了未来很多个“灵异”下午。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进