ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot跨域CORS详解:同源策略、预检请求与配置排查

SpringBoot跨域CORS详解:同源策略、预检请求与配置排查 1. 搞清楚CORS在拦什么同源策略、预检请求与后端响应头的对应关系先从一个我几乎每天都能在群里看到的报错说起。前端的axios请求明明已经发到了SpringBoot接口上后端日志里也打印出了正常处理痕迹但浏览器控制台就是一个大写的“ACCESS TO XMLHTTPREQUEST AT HTTP://LOCALHOST:8080/API/LIST FROM ORIGIN HTTP://LOCALHOST:5173 HAS BEEN BLOCKED BY CORS POLICY: NO ACCESS-CONTROL-ALLOW-ORIGIN HEADER IS PRESENT”。很多人第一反应是去后端找代码问题但如果不懂CORS在干什么调了半天也未必能定位到根子上。这篇文章就把SpringBoot处理跨域这件事从头到尾拆一遍从浏览器同源策略到底层预检机制再到三种主流配置写法、Spring Security下的特殊坑、以及线上排查手段一次性讲透。1.1 同源策略真正“拦你”的不是后端是浏览器要先逼自己接受一个反直觉的事实当页面和接口不同源时后端其实已经收到请求、执行了逻辑、也把响应返回了。真正把数据拦在门外的是浏览器——它出于安全考虑不允许一个网页随意读取另一个源的数据。这个机制就是同源策略Same-Origin Policy。“源”由三部分组成协议、域名、端口。三者完全一致才算“同源”。 http://localhost:5173 去请求 http://localhost:8080 端口不一样跨域 https://www.example.com 去请求 http://api.example.com 协议和域名都不同照样跨域。很多新手以为“域名一样就不算跨域”其实端口不同就足以触发跨域拦截。同源策略本身不是坏东西它是浏览器给网页套的一层沙箱防止恶意站点读取你在银行、邮箱里的数据。但前后端分离的项目天然就把页面和API拆到了不同域名或不同端口上这时候就需要给浏览器开一条合规的“后门”。这条后门就是CORSCross-Origin Resource Sharing跨域资源共享——一个HTTP头协商机制。浏览器放不放行不看后端代码只看后端响应头里有没有正确的 Access-Control-Allow-* 字段。1.2 预检请求复杂请求多出来的一次OPTIONSCORS除了说“允许/不允许”还派生出了“预检请求”这个概念。预检不是每个跨域请求都会触发只有“非简单请求”才会。简单请求的条件比较苛刻方法只能是GET、HEAD、POST请求头基本只能是浏览器允许的那几个常见字段POST的Content-Type只能是 application/x-www-form-urlencoded、multipart/form-data、text/plain 这三种之一。只要条件不满足比如前端用POST提交 JSONContent-Type: application/json或者请求头里带了Authorization、X-TOKEN之类的自定义字段浏览器就会认为这是复杂请求先发一个OPTIONS请求去“探路”。这个OPTIONS请求自带两个请求头 Access-Control-Request-Method 告诉服务端“我接下来要用哪个方法” Access-Control-Request-Headers 告诉服务端“我接下来要带哪些自定义头”。服务端拿到这次询问会返回 Access-Control-Allow-Methods 和 Access-Control-Allow-Headers 等响应头。浏览器比对通过后才会发出真正的业务请求。所以观察跨域报错时你经常能看到两类典型“No Access-Control-Allow-Origin header is present”通常是真实请求的响应头不对“response to preflight request doesnt pass”则是预检请求这一步就已经挂了。两者根源都可能在后端缺少CORS响应头但排查入口不一样。1.3 CORS响应头真正决定放行不放行的是这组头既然关键在响应头那就有必要把这几个头记牢响应头作用注意点Access-Control-Allow-Origin指定允许哪些源读取响应可以写死某个源也可以返回请求方Origin用*表示全部放行但和Cookie场景冲突Access-Control-Allow-Methods预检时声明的允许HTTP方法例如 GET, POST, PUT, DELETE, OPTIONSAccess-Control-Allow-Headers预检时声明的允许请求头有自定义头就加对应名字如 Authorization, Content-TypeAccess-Control-Allow-Credentials是否允许浏览器携带Cookie设true后Allow-Origin必须写具体源不能再是*Access-Control-Max-Age预检结果在浏览器侧的缓存秒数有了它浏览器不会每次请求都先发一次OPTIONS这组头缺失、值错误、和前端预期不一致浏览器都会直接阻断报出五花八门的CORS错误。理解到这一层你再看SpringBoot的配置就会觉得很简单后端要做的就是把上面这组头根据规则填对。2. SpringBoot三套配置打法CrossOrigin、addCorsMappings、CorsFilter各有什么讲究SpringBoot解决跨域的方案看起来有好几种实际核心就是三类在Controller上打注解、实现WebMvcConfigurer做全局配置、注册一个CorsFilter。我见过不少项目三种混着写结果出了诡异问题还不知道是哪套配置在生效。这里把三套玩法讲清楚你才能选对。2.1 CrossOrigin适合临时调试和单接口开放最省事的方式就是往Controller类或者单个接口方法上挂 CrossOrigin 注解。比如CrossOrigin(origins http://localhost:5173) RestController RequestMapping(/api) public class DemoController { GetMapping(/list) public ListString list() { return List.of(a, b); } }它支持的属性不少常用的有 origins 指定允许来源、methods 指定允许方法、allowedHeaders 指定允许的请求头、allowCredentials 指定是否允许Cookie、maxAge 指定预检缓存秒数。不确定的时候直接写 CrossOrigin(origins *) 就能跑通本地联调。但这个方式有两个毛病。第一是配置散落在业务代码里接口一多就失控改一个来源要翻一堆Controller第二是它只对能被SpringMVC HandlerMapping处理到的请求生效。如果请求在Filter层就被拦截、返回或者Controller抛异常后响应头没补齐注解就形同虚设。我的建议是本地开发或者给别人提供个别开放接口时临时用正式项目不要把它当主力方案。2.2 WebMvcConfigurer.addCorsMappings最常用的全局配置大部分前后端分离项目我推荐用全局配置代码非常干净Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }addMapping(/**) 表示对所有路径生效。allowedMethods 里我习惯把OPTIONS一起写上否则预检请求本身可能因为没有匹配的方法被拒。allowedHeaders(*) 允许任意请求头省得前端加了一个自定义头后端没配上就报错。这里有个关键点如果你需要 allowCredentials(true) 同时又要允许任意来源不能用 allowedOrigins()SpringBoot 2.4之后提供了 allowedOriginPatterns()它支持通配符同时不会破坏Cookie携带。如果你用 allowCredentials(true) 又硬上 allowedOrigins(*)启动或运行时控制台会直接报错告诉你这是个非法组合。这个坑后面专门展开。2.3 CorsFilter走在Servlet过滤器链上的全局方案CorsFilter 是Spring实现好的一个Servlet过滤器可以绕过SpringMVC的HandlerMapping直接在更前置的链路里处理跨域。注册方式一般是Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }它和WebMvcConfigurer最大的区别是生效层级。addCorsMappings 走的是SpringMVC内部的CorsInterceptor属于DispatcherServlet之后那一段链路而CorsFilter作为Servlet过滤器在进入DispatcherServlet之前就会执行。正因为它更靠前所以在整合Spring Security的项目里官方推荐的方案往往就是CorsFilter配合Security过滤器链。2.4 三套配置怎么选配置方式配置位置生效范围适用场景CrossOriginController/Method单个接口临时联调、极少量接口开放WebMvcConfigurerSpring配置类SpringMVC内部全局常规前后端分离项目CorsFilterServlet过滤器链全局范围覆盖Filter层Spring Security项目、网关下游服务一个比较实用的判断口径普通项目用WebMvcConfigurer就够了项目里带了Spring Security或者你发现自定义Filter会提前返回响应导致CORS头补不上的果断换CorsFilter。不要三套全上配置叠加之后反而容易出现“明明有一份配置是对的但一系列的过滤器顺序让最终响应头被覆盖”的问题。3. 跨域配置的四个经典翻车点凭据冲突、OPTIONS被拦截、过滤器顺序和配置叠加覆盖网上能搜到的CORS配置代码大同小异但真正让一个后端开发在原地转圈很久的往往是那些“配了好像没配”的细节。我整理了自己踩过、也给别的项目排过的四个高频坑。3.1 allowCredentials(true) 和 allowedOrigins(*) 的硬冲突这是最经典的一个坑错误提示也非常明显When allowCredentials is true, allowedOrigins cannot contain the special value * since that cannot be set on the Access-Control-Allow-Origin response header.为什么不能配 *因为Access-Control-Allow-Credentials: true 的含义是允许浏览器在跨域请求时携带Cookie。如果服务端同时返回 Access-Control-Allow-Origin: *任何网站都能带着用户Cookie去读你的接口这等于把同源策略挖了个大洞浏览器直接拒绝这种组合。解决方案不外乎两种。第一种是明确允许的来源把allowedOrigins写成具体域名第二种是既要带Cookie又想放行所有源就用allowedOriginPatterns()。注意这个API是SpringBoot 2.4之后加进CorsRegistration的CorsFilter里CorsConfiguration也支持 addAllowedOriginPattern。我平时在CorsFilter里固定用 addAllowedOriginPattern()从根上避开这个组合。这里还想提醒一句allowCredentials(true) 只针对Cookie如果你前端用Header里放Token的常规做法理论上不需要开它。很多人一上来把allowCredentials设成true又非要用*结果自己把自己堵死。先搞清楚你的前端到底有没有跨域带Cookie的需求再决定要不要开这个开关。3.2 OPTIONS预检请求被拦截器或权限Filter干掉配置类里方法都写对了但跨域还是报“response to preflight request doesnt pass”。这种时候十有八九是OPTIONS请求没能顺利走到SpringMVC的CORS处理逻辑。常见场景就是项目里自己写了拦截器HandlerInterceptor或者登录鉴权Filter对所有请求先做一遍校验发现OPTIONS方法没有带Token就直接返回401/403。预检请求本身就是浏览器模拟的一次“探路”它不会带业务Token也不会带业务数据。如果拦截器把OPTIONS一刀切拦住后端都没机会返回CORS响应头浏览器自然认为这个源不被允许。解决办法很直接在拦截器或Filter的判断逻辑里放行OPTIONS方法。比如拦截器里的写法Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } // 其他鉴权逻辑 return true; }注意放行的时候最好也顺手确认预检请求需要的 Access-Control-Allow-* 响应头能不能拿到。你用了WebMvcConfigurer方案OPTIONS请求能到HandlerMapping一般会自动补但如果你的Filter先设了响应头、又提前返回后续拦截器不会执行头未必齐全。这也是很多人放行了OPTIONS依然报错的隐藏原因。3.3 自定义Filter把CORS响应头覆盖了SpringBoot项目里经常会注册各种自定义Filter比如日志过滤器、XSS过滤器、接口签名过滤器。如果这些Filter顺序不对或者内部自己往 Response 里写跨域头很容易把CORS配置冲掉。最典型的是你同时写了自定义Filter A 和 CorsFilterA在CorsFilter之前执行而A又设置了自己的跨域头等到CorsFilter执行时发现早已有响应头逻辑一冲突最终返回给前端的头缺胳膊少腿。我见过一个项目老代码里Filter手写了一次CORS后面又接入了SpringCloud Gateway的全局CORS两边同时往响应里塞 Access-Control-Allow-Origin导致同一个请求响应头里出现两个值浏览器按更严格的那个校验前端怎么调都报错。这里定个调子跨域配置维持单一配置源。要么纯用SpringBoot的CorsFilter/MVC配置要么全部交给网关不要在多个Filter里各设置一遍。如果确实需要自定义Filter注意用 Order(Ordered.HIGHEST_PRECEDENCE) 或 FilterRegistrationBean 控制顺序让CorsFilter尽量排在前列。核心思路是CORS头应该在响应生成的最早期就被正确地、唯一地确定下来后面的过滤器都不去动它。3.4 配置叠加覆盖哪一份才是真正生效的有人用CrossOrigin、WebMvcConfigurer、CorsFilter三管齐下结果反而临到头不知道该改哪个。SpringBoot处理CORS的优先级并不是“后配置覆盖前配置”而是不同链路都会按自己的规则去处理。一旦出现多条链路都往Response加头或某条链路先返回另一条链路根本无法执行行为就变得不可预测了。还有一个容易忽略的点addCorsMappings 是指定路径注册的比如 registry.addMapping(/api/)那么 /admin/下面的接口就完全不生效CrossOrigin 注解如果只打在类上方法上又没有单独配类级别的配置会作为方法的默认值但一旦方法自己又写了CrossOrigin就按方法的配置准。所以排查时先确认你这套请求到底命中哪条路径规则是注解生效还是全局配置生效一个请求只应有一条明确的CORS规则否则就拆掉多余的配置。4. 带上Spring Security之后跨域为何还是不通过滤器链和cors()的正确姿势SpringBoot项目一旦引入了Spring SecurityCORS配置失效的概率会突然升高。不是CORS配置本身写错了而是安全过滤器的执行时机把SpringMVC的配置挡在了后面。4.1 为什么CORS配置在Security下失效Spring Security的核心是一大串Servlet过滤器所有这些过滤器默认在DispatcherServlet之前执行。Security会优先拦截请求判断你是否已认证、是否有权限。如果你的WebMvcConfigurer.addCorsMappings只配置在SpringMVC层那么在这一串Security过滤器里跨域头还没被设置如果Security提前拒绝了请求比如未登录返回401响应里根本没有Access-Control-Allow-Origin浏览器直接报CORS错误。换句话说你看到的现象可能是“配置了CORS还是报跨域”其实真实链路是OPTIONS请求到达Security过滤器Security发现这个请求没认证或者CSRF校验没过直接返回了401根本没轮到SpringMVC的CorsInterceptor去补头。这也就是为什么Spring Security项目里官方推荐的跨域方案是CorsFilter——它在过滤器链顶层就把CORS头补好了后面的Security过滤器哪怕拦截了请求响应头也已经存在。4.2 正确姿势http.cors() 加上 CorsConfigurationSourceSpring Security从5.x到6.x配置跨域的标准做法已经比较固定。你需要提供一个 CorsConfigurationSource 的Bean然后在SecurityFilterChain里开启 cors()。Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(cors - cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth - auth .requestMatchers(/api/login, /api/public/**).permitAll() .anyRequest().authenticated()) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable); return http.build(); } Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; } }这段配置里cors() 告诉Security使用CORS处理器configurationSource引用了我们自己的跨域规则。这样Security过滤器链在处理请求前就会先按配置往Response里写入Access-Control-Allow-*头后续无论请求是否被Security拦截浏览器都能看到完整的CORS响应头。如果你希望更保险也可以直接注册一个CorsFilter Bean让它排在过滤器链最前面Security的cors()相形之下只是锦上添花。总之Spring Security项目里不要只配WebMvcConfigurer就指望全链路通必须把CORS的生效层级提到安全过滤器之前。4.3 登录接口、匿名请求和CSRF的特殊场景Spring Security项目里跨域问题还有一个特殊变体登录接口本身可能是公开的但由于CSRF默认开启POST请求需要携带CSRF Token浏览器端没有CSRF Token预检请求一过真实POST又被CSRF过滤器干掉了前端看到可能不是“CORS blocked”而是403。这种时候优先确认CSRF是否是预期关闭的关闭后继续检查CORS头是否完整。另外如果你的Security配置里用了 httpBasic() 或 formLogin()且你前端是纯前后端分离建议显式关掉这两个组件否则登录过程会触发浏览器默认弹窗或Session重定向干扰CORS校验。我实际排查过不少项目CORS配置明明正确结果是被默认表单登录重定向搞乱了响应前端看到的还是一堆不可读的跨域错误。最后提一句Spring Security 6的版本变化它基于Servlet6和SpringBoot3CORS的配置风格和5.x略有差异但核心还是实践中上面这一套。换版本之后如果发现 cors(withDefaults()) 不够用就退回显示指定 configurationSource逻辑上永远成立。5. 线上跨域问题排查与兜底方案浏览器Network、cURL、Nginx与网关层统一收口配置写完了不等于万事大吉。生产环境里你不可能用DevTools去调试用户浏览器跨域问题还需要一套快速定位的手段和兜底策略。5.1 先从浏览器Network面板看OPTIONS预检请求遇到CORS报错别急着看代码先打开浏览器的开发者工具切到Network面板找到当前这个请求。正常情况下复杂请求会先出现一个名为OPTIONS的请求后面才是真实的GET/POST。点开OPTIONS请求看Response Headers。如果里面没有Access-Control-Allow-Origin说明后端的CORS头没补上或者是被过滤器提前拦截了检查后端配置和过滤器顺序。如果这些头都有但真实请求还是被边界再看真实请求的Response Headers里有没有对应头。头齐全了依然报错就检查值是不是能和前端Origin匹配以及allowCredentials和origin的搭配是否合规。另一个高频报错“has been blocked by cors policy: request client is not a secure context”常见于本地HTTP环境里启用了一些安全特性比如部分浏览器要求CORS配合HTTPS。这种时候先把本地开发环境换成localhost再看是不是Secure Context的问题。这种细节排查起来很费时间但知道了就很快。5.2 用cURL模拟跨域看清后端到底返回了什么浏览器虽然会拦截但它不能替你判断后端返回的内容到底是好是坏。想快速复现CORS问题用curl最干净。直接模拟一个带Origin的OPTIONS预检请求curl -i -X OPTIONS http://localhost:8080/api/list \ -H Origin: http://localhost:5173 \ -H Access-Control-Request-Method: GET \ -H Access-Control-Request-Headers: content-type,authorization然后看响应。如果返回了HTTP/1.1 200 Access-Control-Allow-Origin: http://localhost:5173 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说明后端配置没问题问题大概率出在浏览器侧或前端携带参数的方式。如果OPTIONS返回404、405、401或根本没有任何Access-Control开头的头那问题就在后端这条链路。我也习惯用curl检查真实请求的响应头curl -i http://localhost:8080/api/list \ -H Origin: http://localhost:5173这里能看到真实GET请求的Access-Control-Allow-Origin是否被正确拼出。这个方法在排查“真实请求通过预检请求挂了”的场景特别有效。5.3 Nginx反代和网关层的兜底写法很多线上项目前面会挂Nginx或Spring Cloud Gateway。如果你的团队决定把跨域统一在网络层处理后端服务就不再需要重复配置CORS否则两边都写反而可能同时返回重复响应头让浏览器解析出问题。Nginx的典型写法是location /api/ { if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS; add_header Access-Control-Allow-Headers *; add_header Access-Control-Max-Age 3600; add_header Access-Control-Allow-Credentials true; return 204; } add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS; add_header Access-Control-Allow-Headers *; add_header Access-Control-Allow-Credentials true; proxy_pass http://backend; }用 $http_origin 可以动态返回当前请求的源这样不用担心写死某前端域名。需要注意的是如果你整层决定让Nginx管CORS后端服务里那些CrossOrigin、CorsFilter就要关掉至少不要对同一请求重复设置响应头。Spring Cloud Gateway的处理思路类似用CorsConfigurationProperties全局配置由网关统一返回CORS头下游服务做纯业务。5.4 不依赖CORS的其他跨域思路如果项目实在不方便在后端或者网关配置CORS还有几条旁路可以走。第一是反向代理同源化。让Nginx把前端的 /api 前缀请求转发到后端前端页面与接口从浏览器视角看是同一个源。这种方案对老系统改造特别友好后端完全不用动。第二是JSONP。它本质上是在页面里动态插入script标签利用script不受同源限制的特性绕过CORS。缺点很明显只支持GET而且容易受到XSS风险牵连不到万不得已不推荐。第三是处理“非浏览器端”的调用。很多运维会误以为服务端到服务端的HTTP调用也受跨域影响实际上同源策略是浏览器行为后端之间直接HTTP请求是没有任何CORS限制的。所以如果你在做SpringBoot服务之间互相调用完全不用配CORS别把精力花在错误的方向上。第四是WebSocket。WebSocket协议本身不受同源策略约束但要注意它握手阶段还是HTTP如果这个HTTP握手请求遇到CORS限制实际还是会出问题只是后续的通信不再有跨域概念。这一点容易被想用WebSocket绕过跨域的人忽略。排查的经验说到底就是一条先用curl把后端返回的响应头看清楚再根据缺哪个头、值对不对去反推哪一环出了问题。CORS看着琐碎但每一条规则都对应一个具体的产出环节把它当成“响应头检查工具”来理解所有配置就都能串起来了。我自己做项目的习惯是初期就把CorsFilter和Security的配置一气配好后面很少再回来看跨域问题剩下的精力都花在业务上省心得多。
返回列表