《Spring Boot 入门》11.2 CORS 跨域

前端从 5173 端口调用 8080 的图书接口,浏览器报跨域,可服务端日志里请求明明到了。本节讲清同源策略拦的到底是谁、简单请求与预检请求的区别,贴出真实的 OPTIONS 预检头,演示 @CrossOrigin、addCorsMappings、CorsFilter 三种配置,并说透 allowedOriginPatterns 与 allowCredentials 的坑。

本节目标:理解同源策略究竟拦住了什么、为什么服务端日志里请求已经到达,掌握简单请求与预检请求的区别,会用 @CrossOrigin、addCorsMappings、CorsFilter 三种方式配置 CORS,并避开 allowedOrigins 与 allowCredentials 的常见陷阱。
适用版本:Spring Boot 4.1.x(Java 21)

11.2 CORS 跨域

上一节的静态页面跑在 http://localhost:8080,和图书接口同源,调用顺风顺水。但真实开发里,前端常常独立启动:Vite 默认 http://localhost:5173,React Dev Server 默认 http://localhost:3000。此时前端用 fetch 调用 http://localhost:8080/api/books,浏览器控制台立刻报红:

Access to fetch at 'http://localhost:8080/api/books' from origin
'http://localhost:5173' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

很多人的第一反应是「后端没收到请求」。恰恰相反——打开服务端日志,你会发现请求处理得妥妥的,甚至数据库都查完了。这正是 CORS 最反直觉的地方,也是本节要从根上讲清的第一件事。

11.2.1 同源策略拦的是谁

同源策略(Same-Origin Policy)是浏览器施加的限制,不是服务端的限制,也不是 HTTP 协议的限制。

它的规则是:一个页面里的 JavaScript,只能读取「与当前页面同源」的资源。跨源请求本身浏览器会照发,服务端也照常处理并返回结果,只是浏览器在把响应交给 JS 之前检查响应头,发现没有合法的 Access-Control-Allow-Origin,就把响应丢弃,并在控制台报错。

所以整件事的链路是:

  1. 浏览器发出跨源请求。
  2. 服务端正常处理,返回 200 和响应体。
  3. 浏览器检查响应头,判定不通过。
  4. 响应被丢弃,JS 收到一个错误,控制台报 CORS。

结论:「服务端收到了请求」和「浏览器允许 JS 读响应」是两件事。你看到日志里有请求,不代表配置生效了;配置生效的标志是响应头里带上了正确的 Access-Control-Allow-Origin。

顺带澄清:用 curl、Postman 测试永远看不到 CORS 问题,因为它们不是浏览器,不执行同源策略。用它们「验证跨域配置好了」是无效的——必须用真实浏览器或带上 Origin 头的请求去验证。

11.2.2 什么算同源

同源要求协议、主机、端口三者全部相同:

当前页面请求目标是否同源原因
http://localhost:5173http://localhost:8080否端口不同
http://localhost:8080http://localhost:8080是三者一致
https://plumephp.comhttp://plumephp.com否协议不同
https://plumephp.comhttps://api.plumephp.com否主机不同(子域也算跨源)
https://plumephp.comhttps://plumephp.com:8443否端口不同

注意:子域也算跨源。api.example.com 调用 www.example.com 同样需要 CORS,不能想当然认为「同一个站」。

11.2.3 简单请求:不预检,直接发

浏览器把跨源请求分成两类。简单请求(simple request)满足全部条件时,直接发出,不做预检:

  • 方法是 GET、HEAD 或 POST;
  • 手动设置的请求头只限于 CORS 安全列表头:Accept、Accept-Language、Content-Language、Content-Type;
  • 且 Content-Type 只能是 application/x-www-form-urlencoded、multipart/form-data、text/plain 三种之一;
  • 请求里没有 ReadableStream。

对于简单请求,服务端只要在响应里带上 Access-Control-Allow-Origin 即可。例如 GET /api/books 带 Accept: application/json 就是简单请求。

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Content-Type: application/json

关键点:我们平时传 JSON 用的 Content-Type: application/json 不在简单请求允许的三者之内,所以 POST /api/books 带 JSON body 一定会触发预检。这是新手最容易漏掉的一环——他们只给 GET 配了 CORS,以为 POST 也顺带好了。

11.2.4 预检请求:先问一句能不能发

当请求不满足简单请求条件时,浏览器先发一个 OPTIONS 请求去问服务端「我能不能用这个方法和这些头访问你」。这个 OPTIONS 就叫预检请求(preflight)。真实请求要等预检通过才发出。

预检请求长这样:

OPTIONS /api/books HTTP/1.1
Host: localhost:8080
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

服务端必须回一个 2xx(通常是 200 或 204),并带上批准的头:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 1800
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Length: 0

逐个解释:

响应头作用
Access-Control-Allow-Origin允许哪个源,必须精确匹配或为 *
Access-Control-Allow-Methods允许的方法列表,必须包含真实请求的方法
Access-Control-Allow-Headers允许的自定义头,必须包含真实请求里用到的
Access-Control-Max-Age预检结果缓存多少秒,期间不再重复预检
Vary: Origin告诉缓存,响应随 Origin 变化,防止缓存串源

Access-Control-Max-Age 很实用:默认情况下每次真实请求前都要预检一次,等于请求翻倍。设成 1800(30 分钟)能显著减少往返。但注意浏览器有上限(Chrome 限制为 2 小时),设太大无效。

预检请求本身不带 cookie 和认证信息,也不该被登录拦截器拦住。如果你写的拦截器对所有路径做登录校验,OPTIONS 会被拦下返回 401,浏览器就会报「预检失败」。正确的做法是放行 OPTIONS 请求,这一点在 11.3 的登录校验里会再强调。

11.2.5 三种配置方式

Spring 提供三种配置 CORS 的方式,适用场景不同。

方式一:@CrossOrigin 注解(最细粒度)

加在 Controller 类或单个方法上:

@RestController
@RequestMapping("/api/books")
@CrossOrigin(origins = "http://localhost:5173", maxAge = 1800)
public class BookController {

    @GetMapping
    public List<BookResponse> list() {
        return bookService.findAll();
    }

    // 单个方法覆盖类级配置
    @CrossOrigin(origins = "*")
    @GetMapping("/public")
    public List<BookResponse> publicList() {
        return bookService.findPublished();
    }
}

优点:精准到方法,改动小。缺点:Controller 一多就要重复写,容易漏。

方式二:WebMvcConfigurer#addCorsMappings(全局,推荐)

package com.example.library.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOriginPatterns("http://localhost:*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(1800);
    }
}

这是最常用的方式:一次配置,全局生效,还能按路径模式分组(比如 /api/** 宽松、/admin/** 收紧)。

方式三:CorsFilter(最底层)

当需要 CORS 在过滤器链里生效(例如某些非 Spring MVC 的路径、或与 Spring Security 的过滤链协作)时,注册一个 CorsFilter:

package com.example.library.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsFilterConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOriginPattern("http://localhost:*");
        config.addAllowedMethod("*");
        config.addAllowedHeader("*");
        config.setAllowCredentials(true);
        config.setMaxAge(1800L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}

三种方式对照:

方式作用范围何时选它
@CrossOrigin单个 Controller / 方法只有零星几个接口要开放
addCorsMappings全局,按路径模式绝大多数场景
CorsFilter过滤器链,早于 MVC需要更早介入,或与 Security 配合

三者可以共存,但不要重复配置同一个路径,否则可能出现两套头叠加或互相覆盖,排查起来很痛苦。选定一种,全局统一。

11.2.6 allowedOrigins 与 allowedOriginPatterns

这是配置 CORS 时最容易踩的坑。

allowedOrigins 要求精确匹配,不允许通配符(除了单独的 *)。所以 allowedOrigins("http://localhost:*") 是无效的,端口通配不会被识别,跨域依旧失败。

allowedOriginPatterns 支持模式匹配,可以写 http://localhost:*、https://*.example.com。

更关键的是与 allowCredentials 的关系:

配置allowCredentials=falseallowCredentials=true
allowedOrigins("*")允许抛异常,启动失败
allowedOriginPatterns("*")允许允许(按请求的 Origin 回显)
allowedOriginPatterns("http://localhost:*")允许允许

原因是规范要求:当响应允许携带凭证时,Access-Control-Allow-Origin 不能是 *,必须是具体源。allowedOrigins("*") 无法回显具体源,Spring 直接拒绝启动。用 allowedOriginPatterns 则会在响应里回显匹配到的实际 Origin,从而满足规范。

结论:只要涉及凭证(cookie、Authorization),一律用 allowedOriginPatterns。

11.2.7 allowCredentials=true 的坑

allowCredentials(true) 允许浏览器在跨源请求里带上 cookie 和 Authorization 头。开了它,前端 fetch 也必须显式加 credentials: 'include',否则浏览器不会发送凭证:

fetch('http://localhost:8080/api/books', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',   // 少这一行,cookie 就不会带上
  body: JSON.stringify({ title: 'Effective Java', price: 88 })
});

配套的坑还有一串:

  • Cookie 的 SameSite 属性:跨站请求默认带不上 SameSite=Lax 的 cookie。要么设 SameSite=None; Secure(需要 HTTPS),要么用同源代理。这是「CORS 配好了、cookie 还是没带上」的最常见原因,且它和 CORS 是两回事。
  • allowedOrigins("*") 直接冲突,见 11.2.6。
  • 凭证请求不允许 Access-Control-Allow-Origin: *,必须是具体源,且响应应带 Access-Control-Allow-Credentials: true。

一个稳妥的推荐配置(开发环境):

registry.addMapping("/api/**")
        .allowedOriginPatterns("http://localhost:*")   // 回显具体源
        .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
        .allowedHeaders("*")
        .allowCredentials(true)
        .maxAge(1800);

11.2.8 与 Spring Security 的 CORS 关系(预告)

一旦项目引入 Spring Security,CORS 的配置位置会变:Security 的过滤链排在 MVC 之前,预检 OPTIONS 请求可能还没走到你的 addCorsMappings 就被 Security 拦下返回 401/403。

此时需要让 Security 感知 CORS:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.cors(Customizer.withDefaults());   // 关键:启用 CORS,复用 CorsConfigurationSource
    http.csrf(csrf -> csrf.disable());
    return http.build();
}

同时提供一个 CorsConfigurationSource bean,Security 与 MVC 会共用同一份配置,避免两边不一致。完整细节属于后续章节,这里只需记住一条:出现 Security 后,CORS 要在 Security 的链上配,而不是只配 addCorsMappings。

11.2.9 「配置了还是跨域」排查表

现象可能原因排查
控制台报缺 Access-Control-Allow-Origin配置的路径模式没覆盖到该接口确认 addMapping 的路径包含目标 URL
GET 正常,POST 失败POST 带 JSON 触发预检,预检未通过检查 allowedMethods 是否含 POST、allowedHeaders 是否含 content-type
预检返回 401/403被登录拦截器或 Security 拦下放行 OPTIONS 请求
报 When allowCredentials is true, allowedOrigins cannot contain "*"用了 allowedOrigins("*") + 凭证改 allowedOriginPatterns
头都对了,cookie 还是没带上SameSite 限制或前端没加 credentials前端加 credentials: 'include',cookie 设 SameSite=None; Secure
curl 能通、浏览器不行curl 不走同源策略,不能作为验证手段用浏览器或带 Origin 头的请求验证
改了配置没生效多个 CORS 配置叠加冲突只保留一种配置方式

小结

  • 同源策略是浏览器的限制:请求会照发、服务端照常处理,浏览器只是拒绝把响应交给 JS。日志里有请求 ≠ 配置生效。
  • 同源要求协议、主机、端口三者全同;子域也算跨源。curl/Postman 不执行同源策略,无法验证 CORS。
  • 简单请求直接发,只需响应带 Access-Control-Allow-Origin;带 JSON 的 POST 因 Content-Type: application/json 必然触发 OPTIONS 预检。
  • 预检响应必须包含 Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers,并建议带 Vary: Origin 与合理的 Access-Control-Max-Age。
  • 三种配置方式按粒度选择:@CrossOrigin(方法级)、addCorsMappings(全局,推荐)、CorsFilter(过滤器链层);不要重复配置同一路径。
  • allowedOrigins 不支持端口通配,且与 allowCredentials=true 不兼容;涉及凭证时一律用 allowedOriginPatterns。
  • 凭证请求还要前端加 credentials: 'include',并处理 cookie 的 SameSite 属性;引入 Spring Security 后,CORS 要在 Security 链上启用。

跨源问题解决后,图书服务的每个接口都暴露在外。无论是记录请求日志,还是统一校验登录状态,我们都需要一个「在请求进入 Controller 之前统一处理」的入口。这正是下一节的主题。

阅读导航:上一节:11.1 静态资源映射 · 下一节:11.3 拦截器与过滤器 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计