2190 字
约 7 分钟
16
S07-X04 · 前后端分离下的跨域:CORS 与代理的取舍

"跨域被拦了"这句话经常是误判:请求其实已经到达服务器、数据库也写了,只是浏览器拒绝把响应交给 JS。把这条搞反,就会去后端加一堆头,而问题出在架构层——前端和后端本该同源。

这篇按"限制什么 → 预检怎么判 → 头怎么写 → 三种解法怎么选 → 坑"走,配置以 Spring Boot 3.3.x 与 nginx 1.25 为准。


1. 同源策略限制的是"读",不是"发"

同源 = 协议、主机、端口三者完全相同。任一不同即跨源:

页面地址 请求地址 是否同源 说明
https://a.com https://a.com/api 是 路径不参与判定
https://a.com http://a.com/api 否 协议不同
https://a.com https://www.a.com/api 否 主机不同(子域也算跨源)
https://a.com https://a.com:8443/api 否 端口不同
http://localhost:5173 http://localhost:8080/api 否 开发期最常见的一种

关键点:跨源请求通常会正常发出,服务端也会正常处理,浏览器只是在把响应交给 JS 之前做了拦截。所以"跨域导致订单没创建"这种说法几乎总是错的——先看服务端日志,再判断是不是仅前端读不到响应。

唯一的例外是预检失败:预检没通过时,真正的请求根本不会发出。


2. 简单请求与预检

浏览器把跨源请求分成两类,判定规则是固定的:

条件 简单请求 需要预检(OPTIONS)
方法 GET、HEAD、POST PUT、DELETE、PATCH 等
请求头 仅 CORS 安全列表(Accept、Accept-Language、Content-Language) 含 Authorization、X-Requested-With、自定义头
Content-Type text/plain、multipart/form-data、application/x-www-form-urlencoded application/json
其他 无 ReadableStream、无上传进度监听 任一不满足

这就是"为什么 POST 也会触发预检"的答案:方法在安全列表里,但 Content-Type: application/json 不在。JSON 接口几乎必然走预检,PUT/PATCH 同理。

$ curl -i -X OPTIONS http://localhost:8080/api/users \
    -H "Origin: http://localhost:5173" \
    -H "Access-Control-Request-Method: POST" \
    -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-Max-Age: 1800

预检请求不带 cookie、不带认证头,也不带请求体。所以任何"必须登录才能通过"的过滤器都会把它拦掉——这是后面坑表里的第一条。


3. 响应头逐条

响应头 作用 常见错误
Access-Control-Allow-Origin 允许的来源,或 * 带 cookie 时写 *,浏览器直接拒
Access-Control-Allow-Methods 预检时声明允许的方法 只写 GET,POST,PUT 被拒
Access-Control-Allow-Headers 预检时声明允许的请求头 漏 authorization,登录后所有请求失败
Access-Control-Allow-Credentials 是否允许携带 cookie 与 * 组合,规范禁止
Access-Control-Max-Age 预检结果缓存秒数 写 0 或太小,每个请求都多一次 OPTIONS
Access-Control-Expose-Headers 允许 JS 读取的非安全响应头 前端读不到 X-Total-Count 分页信息
Vary: Origin 缓存键要区分来源 缺它导致 CDN 把 A 站的响应发给 B 站

带 cookie 时 Allow-Origin 不能是 *,原因是 * 表示"任何来源都可读",而 Credentials: include 意味着"带着用户身份读"——两者相乘等于任意站点都能以用户身份读取数据。所以规范要求此时必须回显具体来源,并且 Allow-Credentials: true,同时响应必须带 Vary: Origin 以保证缓存正确。

减少预检的三种做法:

  1. 把 Max-Age 调大(浏览器有上限,Chrome 约 2 小时封顶,按版本核对);
  2. 让接口落在同源路径下(反代),从根上不存在跨源;
  3. 把自定义头收敛,能放 query/body 的不要塞 header。

4. 三种解法对照

方案 做法 成本 适用
后端 CORS 后端返回 CORS 头,允许前端域 每套环境都要配、要维护白名单 前后端域名长期分离、开放 API
开发期代理 Vite/webpack devServer 转发 /api 只在开发机生效 本地联调,避免改后端
生产同域反代 nginx 同域下挂前端与 /api 一次配置,长期零成本 绝大多数自有站点
// vite.config.js:开发期代理,浏览器视角始终是同源
export default {
  server: {
    proxy: {
      '/api': { target: 'http://localhost:8080', changeOrigin: true }
    }
  }
}

代理生效后,前端代码里所有请求都写相对路径 /api/...。这也是三条方案里唯一"改前端代码最少"的一条:只要不写死后端域名,切到生产同域反代时一行都不用改。

# 生产:同域,前端静态文件与 API 同一个 origin
server {
    listen 443 ssl;
    server_name app.example.com;

    root /var/www/app/dist;
    index index.html;

    location /api/ {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        try_files $uri $uri/ /index.html;    # 前端路由回退
    }
}

同域之后,CORS 头一条都不用加,预检也不存在。这是"消除跨域"而不是"绕过跨域"——差别在于后者永远要维护白名单。


5. Spring 侧的两处配置与优先级

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "https://app.example.com", maxAge = 1800)
public class UserController { /* ... */ }
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://app.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(1800);
    }
}
配置位置 作用范围 与另一处的关系
@CrossOrigin(类/方法) 单个 handler 与全局配置合并,局部值优先
addCorsMappings 按路径匹配 作为全局基线
CorsFilter(FilterRegistrationBean) 全链路,含静态资源 与上面两者叠加会产生重复头
nginx add_header 反代层 与后端头叠加同样重复

@CrossOrigin 与全局 addCorsMappings 不会产生"两份头",Spring 在 AbstractHandlerMapping 里把它们合并成一个 CorsConfiguration(局部优先),只写一次响应头。真正会重复的是 CorsFilter + MVC 配置、或 nginx + 后端各写一次。

# 验证是否重复:响应头出现两行就是叠加了
$ curl -sI https://app.example.com/api/users -H "Origin: https://app.example.com" | grep -i access-control

6. 常见坑

坑 现象 原因 处理
Allow-Credentials: true + Allow-Origin: * 控制台报 "credentials mode is 'include'" 规范禁止组合 回显具体来源,并加 Vary: Origin
预检 403 真实请求从未发出 鉴权 Filter 拦了不带 cookie 的 OPTIONS 放行 OPTIONS,或在 Filter 里先处理 CORS
只配了 GET/POST PUT 请求预检失败 Allow-Methods 没列全 显式列全,或允许 *(不带 credentials 时)
前端读不到自定义头 X-Total-Count 为 undefined 未设 Expose-Headers 加上对应头名
前后端都配 CORS 报 "Multiple values for header" 两份头被浏览器合并成非法值 只保留一处
预检每次都要发 每个请求都多一个 OPTIONS Max-Age 为 0 或未设 设 600–7200,注意浏览器上限
反向代理后变成跨域 明明同域却报跨域 proxy_pass 改了 Host/协议,或前端写死了后端域名 统一用相对路径 + 保留原始 Host

7. 边界:哪些情况根本不该用 CORS

场景 更合适的做法 理由
自有前后端同一域名 nginx 同域反代 CORS 头只是补偿,反代是消除
移动端/桌面客户端 不需要 CORS 不是浏览器,没有同源策略
对外开放 API 令牌 + 明确白名单 * 只适合完全公开的只读接口
本地联调 devServer 代理 不动后端、不污染生产配置
需要跨站带 cookie SameSite=None; Secure + 精确来源白名单 单靠 CORS 头不够

一句话:CORS 是浏览器强加的一道读权限检查,能在架构层把它消掉(同域反代),就不要在应用层天天维护白名单。


8. 自测

  1. 跨域请求被拦时,服务端有没有收到并处理这次请求?预检失败时呢?
  2. 为什么 POST + application/json 也会触发预检?
  3. 带 cookie 的场景下,Allow-Origin: * 为什么被规范禁止?
  4. @CrossOrigin 与全局 addCorsMappings 同时存在时,会产生两份响应头吗?
  5. 预检请求为什么会被鉴权过滤器拦掉?正确的处理位置在哪?
  6. 前端要读 X-Total-Count,缺哪个响应头?
S07-X04 · 前后端分离下的跨域:CORS 与代理的取舍
https://www.clxhxhhr.top/posts/5810/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。