安全与便利的天平往往难以把握——过于宽松的配置可能导致敏感数据泄露,过于严格的策略又会影响正常业务交互

什么是跨域?为什么会有跨域?

同源策略

其实这是一个很简单的东西,但是配置不好确实很容易让人头疼

CORS 的出现是为了解决浏览器的同源策略限制,我们先从同源策略说起

浏览器这样定义的同源:两个 URL 的协议、域名(主机名)、端口三者完全相同,才被视为 同源。

所以说,在开发前后端分离的项目的时候,由于前端项目和后端项目运行在不同的端口上,天然跨域产生了,配置不好的话经常会出现这种导致的500的问题,浏览器发起请求时,请求会发送出去,服务器也会处理并返回,但是浏览器会在门口把响应拦截下来并报错,不让前端 JS 代码拿到数据。

同源策略其实是浏览器的一种安全机制,防止恶意网站通过 JavaScript 获取另一个网站的敏感数据(比如 cookie、localStorage、API 返回的用户信息)。如果没有同源策略,黑客可以轻易伪造用户请求,窃取数据。

浏览器默认遵循同源策略。它规定:A 网站的 JavaScript (如 localhost:3000) 只有在和 B 网站 (如 localhost:8080) 同源的情况下,才能读取 B 返回的数据。

CORS 机制

CORS 是一套 HTTP 协议机制,是W3C 标准。它允许服务器告诉浏览器:“我是自愿把数据给这个域名的,别拦截了,浏览器大人放过我”。

这主要通过 HTTP 响应头 来控制:

  • Access-Control-Allow-Origin: 允许哪些域名访问(例如 http://localhost:3000)。
  • Access-Control-Allow-Methods: 允许哪些 HTTP 方法(GET, POST, PUT…)。
  • Access-Control-Allow-Headers: 允许携带哪些头信息(Authorization, Content-Type…)。
  • Access-Control-Allow-Credentials: 是否允许携带 Cookie/认证信息。

它允许浏览器向跨域的服务器发送请求,并让服务器决定是否允许这个跨域请求,是同源策略的安全扩展

而且,CORS 的核心是服务器端配置,浏览器会根据服务器返回的响应头,判断是否允许当前页面的跨域请求。

浏览器行为

很多时候报错是因为不懂浏览器的预检请求(Preflight Request)机制。

浏览器将跨域请求分为两类:

简单请求

满足以下所有条件的请求为简单请求:

  • 请求方法是以下三种之一:GETHEADPOST
  • 请求头仅包含以下允许的头信息:AcceptAccept-LanguageContent-LanguageContent-Type(仅允许application/x-www-form-urlencodedmultipart/form-datatext/plain

那么简单请求的处理如下:

  1. 浏览器直接发送跨域请求,请求头中会自动添加Origin字段(表示当前请求的源,如http://localhost:3000)。
  2. 服务器收到请求后,返回的响应头中会包含Access-Control-Allow-Origin等 CORS 相关头。
  3. 浏览器检查响应头:如果Access-Control-Allow-Origin包含当前源(或为*),则允许页面获取响应数据;否则,浏览器会拦截响应,抛出跨域错误(注意:此时服务器已经处理了请求,只是浏览器拦截了响应,是一个特殊的500)。

预检请求

不满足简单请求条件的请求,会触发预检请求(也叫 OPTIONS 请求),这是浏览器的 “提前询问” 机制,这个请求不带 Body,不带 Token,只是询问服务器:“我能发这个请求吗?”

  • 如果服务器对 OPTIONS 回复 200 OK 且带上允许的 Header,浏览器才会发第二次真正的业务请求。
  • 如果服务器拦截了 OPTIONS 请求(比如因为没有 Token 报了 401/403),CORS 失败。

例如,请求方法是PUT/DELETE、请求头包含Authorization/Content-Type: application/json、自定义头(如X-Token)。

那么预检请求是如何处理的:

  1. 浏览器先发送一个OPTIONS请求(预检请求),包含以下关键头:
    • Origin:当前请求的源。
    • Access-Control-Request-Method:后续要发送的真实请求的方法(如PUT)。
    • Access-Control-Request-Headers:后续要发送的真实请求的自定义头(如Content-Type, X-Token)。
  2. 服务器收到预检请求后,需要返回包含以下 CORS 头的响应,告知浏览器是否允许该跨域请求:
    • Access-Control-Allow-Origin:允许的源。
    • Access-Control-Allow-Methods:允许的请求方法。
    • Access-Control-Allow-Headers:允许的请求头。
    • Access-Control-Max-Age:预检请求的缓存时间(单位:秒),缓存期间无需重复发送预检请求。
  3. 如果服务器的响应满足要求,浏览器才会发送真实的请求;否则,浏览器直接抛出跨域错误,不会发送真实请求。

什么叫预检请求的 galgame 场景

  1. 浏览器:“服务器你好,我等会儿想发一个带 Authorization 头的 POST 请求,你允许吗?” (这是 OPTIONS 请求)
  2. 服务器:“允许,发吧。” (返回 200 OK + CORS Headers)
  3. 浏览器:“好的,这是真正的 POST 请求。” (这才是你的业务请求)

注意:OPTIONS 请求是不带 Token 的!

如果你的 Spring Security 过滤器链配置得太严,它一看:“哟,这个 OPTIONS 请求没带 Token?401 滚粗!

浏览器收到了 401,认为服务器不允许跨域,直接报错。你的业务代码(Controller)连执行的机会都没有。

img

附带凭据的请求

总所周知两种包含第三种))))))))

其实只是我习惯单拿出来,因为它的配置确实不太一样

实际上,经常出现 CR ,也就是Credentials Request,如果如果前端请求需要携带凭据(如 cookie、HTTP 认证信息、Token 在 cookie 中),需要满足:

  • 前端需要在请求中设置withCredentials: true,如 Axios 中axios.defaults.withCredentials = true
  • 服务器这边响应头中必须设置Access-Control-Allow-Credentials: true,且Access-Control-Allow-Origin不能是\*(必须是具体的源,如http://localhost:3000)。

Spring Security如何配置服务端跨域

Spring Security 过滤器链中配置 CORS

在 Spring Security 7 (以及 6.x) 中,配置风格全面转向了 Lambda DSL。

很多人在 Spring MVC 层(比如用 @CrossOrigin 注解或 WebMvcConfigurer)配了 CORS,结果还是报 403。

因为 Spring Security 的过滤器链(Filter Chain)执行顺序在 Spring MVC 之前。如果请求在 Security 这一层被拦下了,MVC 层根本收不到请求,配置自然无效。

所以,必须在 Spring Security 过滤器链中配置 CORS。

存在这样的一个标准配置模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. 开启 CORS 配置,显式使用 Bean
.cors(cors -> cors.configurationSource(corsConfigurationSource()))

// 2. 关闭 CSRF (前后端分离通常使用 Token,不需要 CSRF Session 防护)
.csrf(csrf -> csrf.disable())

// 3. 其他权限配置
.authorizeHttpRequests(auth -> auth
// 注意:对于 OPTIONS 预检请求,Spring Security 默认会自动放行(只要配置了 cors())
// 但如果你有特殊的拦截逻辑,确保 OPTIONS 请求被允许
.requestMatchers("/api/public/**").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated()
);

return http.build();
}

/**
* 核心:配置 CORS 规则源
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();

// 1. 允许的来源:建议指定具体域名,生产环境不要用 "*"
// 如果要允许携带 Cookie,不能用 "*",必须指定具体域名
configuration.setAllowedOrigins(List.of("http://localhost:3000", "https://your-domain.com"));
// 或者使用 pattern
// configuration.setAllowedOriginPatterns(List.of("http://*.domain.com"));

// 2. 允许的方法
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));

// 3. 允许的头信息(前端传过来的)
// "Authorization" 用于 JWT,"Content-Type" 用于 JSON
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));

// 4. 是否允许携带凭证(Cookie / SSL证书)
// 如果前端 axios 配置了 withCredentials: true,这里必须为 true
configuration.setAllowCredentials(true);

// 5. 暴露的头信息(前端 JS 能读取到的响应头)
// 默认情况下,前端只能读到简单的响应头,读不到自定义头(如分页总数、新的 Token)
configuration.setExposedHeaders(List.of("X-Total-Count", "Authorization"));

// 6. 应用到所有路径
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

这段代码做了两件事:

  1. 定义规则 (CorsConfigurationSource):告诉 Spring 哪些域是合法的。

  2. 挂载过滤器 (http.cors(...)):Spring Security 会在过滤器链的最顶端加一个 CorsFilter。这个之前的过滤器链顺序中提到过这件事

    • 当请求进来,CorsFilter 先检查。

    • 如果是 OPTIONS 预检请求,CorsFilter 会直接基于你的规则返回 200 OK + CORS Headers,不会让请求继续走到后面的 AuthenticationFilter(认证过滤器)。

      这样就避免了 “预检请求没有 Token 被 401 拦截” 的经典死循环。

注意,Postman 不遵守同源策略,它是开发者工具,不负责浏览器安全。不要用 Postman 测 CORS。一定要用浏览器(Chrome Network Tab)看 Response Headers。

配置 Spring Web 的 CORS

这回再讲如何配置 Spring Web 的 CORS

Spring Framework 自 4.2 起在 MVC 层面引入了对 CORS 的原生支持,可通过全局配置或注解方式开启

全局配置(WebMvcConfigurer)

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class CorsGlobalConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") // 应用到所有 /api 路径
.allowedOrigins("https://your-domain.com") // 允许的源
.allowedMethods("GET","POST","PUT") // 允许的方法
.allowedHeaders("Content-Type","Authorization")
.exposedHeaders("X-Total-Count")
.allowCredentials(true) // 允许携带 Cookie
.maxAge(3600); // 预检结果缓存 1 小时
}
}

实际上经常把 CORS 配置单拿出来进行全局配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig {

/**
* 全局CORS过滤器(推荐,优先级高于WebMvcConfigurer)
*/
@Bean
public CorsFilter corsFilter() {
// 1. 创建CORS配置对象
CorsConfiguration config = new CorsConfiguration();
// 允许的源(生产环境请替换为具体的前端域名,不要用*,尤其是带凭据的请求)
// 多个源可以用config.addAllowedOrigin("http://localhost:3000")逐个添加
config.addAllowedOriginPattern("*"); // Spring 5.3+推荐用allowedOriginPattern,支持通配符(如http://*.example.com)
// 允许携带凭据(cookie)
config.setAllowCredentials(true);
// 允许的请求方法(*表示所有)
config.addAllowedMethod("*");
// 允许的请求头(*表示所有)
config.addAllowedHeader("*");
// 暴露的响应头(前端可以获取的响应头,如Token)
config.addExposedHeader("Authorization");
// 预检请求的缓存时间(秒)
config.setMaxAge(3600L);

// 2. 配置CORS源(哪些URL生效)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 所有URL都应用该CORS配置
source.registerCorsConfiguration("/**", config);

// 3. 返回CORS过滤器
return new CorsFilter(source);
}
}

局部 CORS 配置(@CrossOrigin 注解)

适用于单个控制器或接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

// 对整个控制器的所有接口生效
@RestController
@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true", maxAge = 3600)
public class TestController {

// 对单个接口生效(优先级高于控制器上的注解)
@GetMapping("/api/test")
@CrossOrigin(origins = "*")
public String test() {
return "success";
}
}

别忘了,Spring Security 7 的过滤器链会优先于 CORS 过滤器执行,如果不配置,预检请求(OPTIONS)会被 Spring Security 拦截(比如被 CSRF、认证过滤器拦截),导致跨域失败。

前后端联合调试方案

假设前端部署在 https://frontend.app,后端 API 接口在 https://your-domain.com

首先,在后端,我们需要明确告诉浏览器:“我信任 https://frontend.app,允许它带 Token 过来,也允许它读取数据。”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. 挂载 CORS 配置
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 2. 关闭 CSRF (REST API 不需要)
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
// 3. 这里的 OPTIONS 请求会被 .cors() 自动放行,无需额外配置
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();

// 必须精确匹配前端域名,不要用 "*"
// 结尾不要带 "/" (例如 https://frontend.app/ 是错的)
configuration.setAllowedOrigins(List.of("https://frontend.app"));

// 允许的方法
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));

// 允许前端发送的头
// 缺少 Authorization 会导致 Token 请求被浏览器拦截
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));

// 允许携带凭证 (Cookie/Session)
// 即使你用 JWT,如果前端开启了 withCredentials,这里也必须为 true
configuration.setAllowCredentials(true);

// 暴露响应头 (如果前端需要从 Header 拿 Token 或分页信息)
configuration.setExposedHeaders(List.of("Authorization", "X-Total-Count"));

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

前端的主要任务是:携带正确的 Token 并在请求配置中开启凭证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import axios from 'axios';

// 1. 创建实例
const service = axios.create({
baseURL: 'https://your-domain.com', // 后端地址
timeout: 5000
});

// 2. 请求拦截器:自动携带 Token
service.interceptors.request.use(
config => {
// 假设 Token 存在 localStorage
const token = localStorage.getItem('token');
if (token) {
// 必须与后端 AllowedHeaders 里的内容对应
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);

// 3. 响应拦截器
service.interceptors.response.use(
response => response.data,
error => {
console.error('请求出错:', error);
return Promise.reject(error);
}
);

export default service;

假设前端发起了一个 POST /api/user/profile 的请求。

  1. 观察预检请求 (Preflight / OPTIONS)

    你会先看到一个名为 profile 的请求,方法是 OPTIONS。

    • 状态码:必须是 200 OK (如果是 401/403,说明后端 Security 没配好)。

    • 关键响应头 (Response Headers)

      • Access-Control-Allow-Origin: https://frontend.app (必须完全匹配)
      • Access-Control-Allow-Methods: POST, …
      • Access-Control-Allow-Headers: Authorization, Content-Type (必须包含你请求里带的头)

    如果 OPTIONS 红了:

    • 原因:Spring Security 拦截了 OPTIONS。
    • 检查:确认 SecurityConfig 里 .cors(…) 是否调用,确认 CorsFilter 是否在 Filter 链中。
  2. 观察实际请求 (Actual Request)

    如果 OPTIONS 成功了,紧接着会出现第二个 profile 请求,方法是 POST。

    • Request Headers:必须包含 Authorization: Bearer xxx。
    • Response Headers:同样必须包含 Access-Control-Allow-Origin: https://frontend.app

注意,实际中,有些情况下,如果后端直接抛出 500 异常,Spring 默认的错误处理机制可能不会加上 CORS 头,导致浏览器报 CORS 错误(掩盖了真实的 500 错误)。务必查看后端日志!