本节目标:理解同源策略究竟拦住了什么、为什么服务端日志里请求已经到达,掌握简单请求与预检请求的区别,会用 @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,就把响应丢弃,并在控制台报错。
所以整件事的链路是:
- 浏览器发出跨源请求。
- 服务端正常处理,返回 200 和响应体。
- 浏览器检查响应头,判定不通过。
- 响应被丢弃,JS 收到一个错误,控制台报 CORS。
结论:「服务端收到了请求」和「浏览器允许 JS 读响应」是两件事。你看到日志里有请求,不代表配置生效了;配置生效的标志是响应头里带上了正确的 Access-Control-Allow-Origin。
顺带澄清:用
curl、Postman 测试永远看不到 CORS 问题,因为它们不是浏览器,不执行同源策略。用它们「验证跨域配置好了」是无效的——必须用真实浏览器或带上Origin头的请求去验证。
11.2.2 什么算同源
同源要求协议、主机、端口三者全部相同:
| 当前页面 | 请求目标 | 是否同源 | 原因 |
|---|---|---|---|
http://localhost:5173 | http://localhost:8080 | 否 | 端口不同 |
http://localhost:8080 | http://localhost:8080 | 是 | 三者一致 |
https://plumephp.com | http://plumephp.com | 否 | 协议不同 |
https://plumephp.com | https://api.plumephp.com | 否 | 主机不同(子域也算跨源) |
https://plumephp.com | https://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=false | allowCredentials=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 拦截器与过滤器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。