为什么数字参数不跨域,而编码后的参数却跨域了?—— 深入理解 CORS 简单请求与复杂请求

引言

最近在项目中遇到了一个奇怪的问题:删除文件接口,当传递数字 ID 时可以正常访问,但传递编码后的文件路径时却出现了跨域错误。这个问题困扰了团队一段时间,最终发现根源在于浏览器对 CORS(跨域资源共享)请求的分类处理。本文将深入探讨这一现象背后的原理和解决方案。

问题现象

前端有两个看似相似的请求,结果却截然不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 情况1:传递数字 - 正常执行
export function removeFile1() {
return request({
url: '/api/deleFile/' + 123, // 纯数字
method: 'delete',
});
}

// 情况2:传递编码路径 - 跨域错误
export function removeFile2(fileName) {
return request({
url: '/api/deleFile/' + encodeURIComponent('D:/test.jpg'),
// 实际URL: /api/deleFile/D%3A%2Ftest.jpg
method: 'delete',
});
}

同样的后端接口,为什么一个能通一个不通?让我们深入探究。

CORS 请求的分类

浏览器将跨域请求分为两类:简单请求复杂请求(预检请求)。

简单请求的条件

要成为简单请求,必须同时满足以下所有条件:

  1. 请求方法是以下之一:

    • GET
    • POST
    • HEAD
  2. 请求头仅限于以下字段:

    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(但有限制)
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
  3. Content-Type的值仅限于以下之一:

    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded
  4. 请求中没有使用 ReadableStream 对象

  5. 请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器

我们的请求分析

让我们分析上述两个请求:

请求 1:/api/deleFile/123

  • 方法:DELETE ❌(不满足简单请求条件 1)
  • 但实际上某些浏览器对简单请求的判断比较宽松

请求 2:/api/deleFile/D%3A%2Ftest.jpg

  • 方法:DELETE ❌
  • URL 包含特殊字符%
  • 明显触发复杂请求

浏览器处理流程差异

简单请求的处理流程

1
2
3
4
5
6
7
// 浏览器行为
1. 检查是否是跨域请求
2. 如果是简单请求,直接发送实际请求
3. 在请求头中添加 Origin 字段
4. 服务器响应时检查 Origin
5. 服务器在响应头中添加 Access-Control-Allow-Origin
6. 浏览器检查响应头,决定是否允许跨域

复杂请求的处理流程

1
2
3
4
5
6
7
8
9
10
// 浏览器行为
1. 检查是否是跨域请求
2. 发现是复杂请求,先发送 OPTIONS 预检请求
3. 预检请求包含:
- Access-Control-Request-Method: DELETE
- Access-Control-Request-Headers: (如果需要)
- Origin: http://当前域名
4. 服务器必须响应预检请求,包含正确的CORS
5. 预检通过后,浏览器发送实际请求
6. 服务器响应实际请求

为什么数字参数有时能通过?

这里有几种可能:

1. 浏览器实现差异

不同浏览器对”简单请求”的判定标准略有不同。某些浏览器可能对仅包含数字和字母的路径参数比较宽松。

2. 同源策略的例外

如果后端接口和前端页面在同一服务器、相同端口,可能不会触发严格的 CORS 检查。

3. 开发服务器的代理

开发环境下,前端开发服务器(如 webpack-dev-server)可能代理了请求,绕过了浏览器 CORS 检查。

4. 历史请求缓存

如果之前相同 URL 的请求已经成功,浏览器可能缓存了 CORS 策略。

解决方案

方案 1:统一后端 CORS 配置(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class GlobalCorsConfig {

/**
* 全局CORS配置
* 注意:allowedOriginPatterns比allowedOrigins更灵活
*/
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 所有接口
.allowedOriginPatterns("*") // 允许所有源,生产环境建议指定具体域名
.allowedMethods("*") // 允许所有方法,包括OPTIONS
.allowedHeaders("*") // 允许所有头
.allowCredentials(false) // 是否允许凭证
.maxAge(3600); // 预检请求缓存时间(秒)
}
};
}
}

方案 2: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
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 启用CORS并禁用CSRF(API项目通常禁用CSRF)
.cors().configurationSource(corsConfigurationSource())
.and()
.csrf().disable()

// 权限配置
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 允许OPTIONS请求
.anyRequest().authenticated();
}

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

// 使用allowedOriginPatterns而不是allowedOrigins
configuration.setAllowedOriginPatterns(Collections.singletonList("*"));
configuration.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"
));
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With",
"Accept", "Origin", "Access-Control-Request-Method",
"Access-Control-Request-Headers"
));
configuration.setExposedHeaders(Arrays.asList(
"Access-Control-Allow-Origin", "Access-Control-Allow-Credentials"
));
configuration.setAllowCredentials(false);
configuration.setMaxAge(3600L);

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

方案 3:Nginx 层统一处理

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
server {
listen 80;
server_name api.example.com;

location / {
# CORS headers
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
add_header 'Access-Control-Max-Age' 1728000;

# 处理OPTIONS预检请求
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}

proxy_pass http://backend-server;
}
}

测试和验证

创建测试接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
@RequestMapping("/api/test")
public class CorsTestController {

@GetMapping("/simple/{id}")
public String testSimple(@PathVariable Integer id) {
return "Simple Request: " + id;
}

@DeleteMapping("/complex/{path:.+}")
public String testComplex(@PathVariable String path) {
return "Complex Request: " + path;
}

@RequestMapping(value = "/complex/{path:.+}", method = RequestMethod.OPTIONS)
public ResponseEntity<?> handleComplexOptions() {
return ResponseEntity.ok()
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "DELETE, OPTIONS")
.header("Access-Control-Allow-Headers", "*")
.build();
}
}

前端测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 测试函数
async function testCors() {
console.log('测试1: 简单参数');
try {
const res1 = await fetch('/api/test/simple/123');
console.log('结果1:', await res1.text());
} catch (e) {
console.error('错误1:', e);
}

console.log('\n测试2: 复杂参数');
try {
const res2 = await fetch('/api/test/complex/D%3Atest', {
method: 'DELETE',
});
console.log('结果2:', await res2.text());
} catch (e) {
console.error('错误2:', e);
}
}

最佳实践建议

1. 始终在后端配置 CORS

不要依赖浏览器的宽松策略,明确配置所有需要的 CORS 头。

2. 生产环境指定具体域名

1
2
3
4
5
// 生产环境配置
.allowedOriginPatterns(
"https://www.example.com",
"https://api.example.com"
)

3. 统一请求规范

考虑将路径参数改为查询参数,避免 URL 编码问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 后端
@DeleteMapping("/deleFile")
public ResponseResult deleteFile(@RequestParam String filePath) {
// 业务逻辑
}

// 前端
export function removeFile(filePath) {
return request({
url: '/api/deleFile',
method: 'delete',
params: { filePath }
})
}

4. 监控和日志

添加 CORS 相关的监控和日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class CorsLogFilter implements Filter {

@Override
public void doFilter(ServletRequest req, ServletResponse res,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;

String origin = request.getHeader("Origin");
String method = request.getMethod();

log.info("CORS请求 - Origin: {}, Method: {}, URI: {}",
origin, method, request.getRequestURI());

if ("OPTIONS".equalsIgnoreCase(method)) {
log.debug("处理OPTIONS预检请求");
}

chain.doFilter(req, res);
}
}

总结

这个问题的本质是浏览器安全策略对 CORS 请求的分类处理。简单请求和复杂请求的不同处理流程导致了表面相似但实际行为不同的现象。

关键要点:

  1. URL 中的特殊字符(如%)会触发复杂请求
  2. 复杂请求需要 OPTIONS 预检
  3. 后端必须正确处理 OPTIONS 请求
  4. 统一的 CORS 配置是解决跨域问题的最佳实践

理解和掌握 CORS 机制,对于现代 Web 应用开发至关重要。希望本文能帮助你更好地处理类似的跨域问题。


思考题:除了 URL 编码参数,还有哪些情况会触发复杂请求?欢迎在评论区分享你的发现!