CORS 到底谁说了算?一份跨域配置的避坑指南

改动一个响应头,让整条业务线静默失败数小时------这是跨域问题最典型的形态,一点都不夸张。CORS 看着简单,却是前端、后端、测试三方协作里最容易踩坑的环节之一。这篇文章把原理、利弊、避坑一次讲清,读完你应该能对每一个 CORS 配置问题,说出"为什么这么配、不这么配会怎样"。

一、先纠正一个误区:CORS 是浏览器在把关

很多人以为 CORS 是服务端的一种"授权",其实正好相反------这道关是浏览器把的,服务端只能配合,无法绕过

一次跨域请求的完整判定,就三步:

  1. 客户端发起跨域请求,浏览器自动注入 Origin 请求头。这个头是浏览器强制写入的,脚本删不掉、伪造不了,是 CORS 唯一可信的锚点(定义见 RFC 6454)。
  2. 服务端返回响应,其中携带 Access-Control-Allow-Origin(下称 ACAO)响应头。
  3. 浏览器拿 ACAO 和请求的 Origin 比对:匹配就放行,不匹配或没有就拦截。

所以服务端能做的,只是"正确设置 ACAO 去配合浏览器",而无法"关闭"或"绕过"这道校验------拦截发生在浏览器内部,服务端甚至不知道前端到底有没有成功读到数据。

请求还分两类:简单请求 (GET/HEAD/POST 且仅含简单头)直接发出,收到响应后再校验 ACAO;预检请求 (用了 PUT/DELETE、自定义头、Content-Type: application/json 等)会先发一个 OPTIONS 问"允不允许",通过了才发真正请求。记住一点:预检的触发只跟方法、头、Content-Type 有关,跟 URL 参数无关。

二、最核心的一条规则:带凭据禁用 *

这条值得单独拎出来讲,因为它是 CORS 配置里最容易被写错、也最容易翻车的地方:

只要请求携带凭据,Access-Control-Allow-Origin 就一定不要写 *

"凭据"指 cookie、Authorization 头、TLS 客户端证书。只要带了其中任意一个,浏览器就要求 ACAO 返回明确、具体的源 ,并且同时返回 Access-Control-Allow-Credentials: true。这时写 * 会直接失效。

这不是经验之谈,有权威依据:MDN 的错误页「Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'」和 Fetch Standard 都明确写了这条。

客户端侧,三种写法是等价的:

  • fetch 的 credentials: 'include'
  • axios 的 withCredentials: true
  • XHR 的 xhr.withCredentials = true

特别要留意 fetch 的默认值是 same-origin------同域请求默认带 cookie,跨域请求默认不带 。所以跨域要带 cookie,一定得显式写 include,不是默认行为。

据此,接口按属性可以归成三类,对号入座就能决定怎么配:

接口类型 判据 正确策略
公共只读、无身份 无凭据、无副作用 ACAO 用 *
写操作 / 需身份 有副作用或依赖身份 反射 Origin + 白名单,服务端风控兜底
带凭据 携带 cookie / token 禁用 *,必须反射具体源

三、两种正确做法,以及各自真实的代价

同源代理:治本,但难在"域名收敛"

把跨域请求改成同域路径,由网关反代到目标服务。浏览器视角是同源,根本不触发 CORS,cookie 天然同源携带,后端也不用维护白名单。配置上可以收敛成"一条入口 + 一张路由表",不会随接口数量线性膨胀:

nginx 复制代码
location /gw/ {
    proxy_pass http://gateway_upstream;
}

map $uri $backend {
    ~^/gw/order/   http://order-internal;
    ~^/gw/crm/     http://crm-internal;
    default        http://default-internal;
}

前端调用从 https://api.example.com/order/...(跨域)改成 https://www.example.com/gw/order/...(同域)。

但它的成本不在配置,而在域名收敛------要求所有调用方都走同一个网关域名,这在现实里往往最难推:

  • 历史域名债务:多业务线、不同时期各起各的域名,存量分散在不同团队手里,没人能一句话拍板收敛;
  • 跨团队协调成本:改域名/路径会牵连缓存、埋点、监控、CDN/WAF 白名单、防盗链,牵一发动全身;
  • 网关能力不统一:有的系统有统一网关,有的还是裸 Nginx 或服务直连,不具备统一反代条件。

所以结论是:接口多、域名分散又短期收敛不了的团队,同源代理的隐性成本很高,不宜硬上。这时用下面这套更务实。

反射 Origin + 白名单:通用,但白名单要管好

不能同域的接口,服务端校验 Origin 后原样反射 ,不要用 *。白名单命中才反射,没命中就不返回 ACAO,浏览器自动拒绝。

Spring Boot 里,关键是用 allowedOriginPatterns(Spring 5.3+),而不是 allowedOrigins("*")

java 复制代码
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOriginPatterns(            // 用 allowedOriginPatterns,不能用 allowedOrigins("*")
                "https://www.example.com",
                "https://m.example.com",
                "https://test.example.com"
            )
            .allowedMethods("GET", "POST", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);
    }
}

Nginx / 网关层用 map 正则反射:

nginx 复制代码
map $http_origin $cors_origin {
    default "";
    "~^https://[a-z0-9.-]+\.example\.com$" $http_origin;
}

server {
    location /api/ {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Vary Origin always;

        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
            add_header Access-Control-Max-Age 3600 always;
            return 204;
        }
    }
}

两个配套必须一起做,否则会翻车:

  1. Vary: Origin :告诉缓存层"响应随 Origin 变化"。但只在源站返回 Vary 不够,CDN 还得把 Origin 纳入缓存键(腾讯云/阿里云 CDN 的"自定义 Cache Key"里加 Origin,自研 Nginx 在 proxy_cache_key 里拼 $http_origin)。少做这一步,多站点会互相串缓存。
  2. 白名单正则要锚定 ^...$ :写 example.com 会放行 evilexample.com,必须写完整边界。

四、三个最容易踩的实战盲区

反射 Origin 方案的坑,大多不在"反射"本身,而在"白名单怎么管理":

  1. 多环境域名漏配:测试、预发、线上域名各不相同,白名单如果硬编码线上域名,测试/预发页面调线上接口就直接跨域失败(端口也参与匹配)。正确的做法是白名单从配置中心或环境变量注入,不硬编码。
  2. 后端先行发布:接口先上线、前端还在预发时,预发页面调线上接口是标准发布流程,却会被白名单误伤。正确做法是预发走同源代理,或把预发域名当作一等环境常驻白名单。
  3. 白名单管理失控:为了联调临时加域名、事后忘了撤,白名单越积越多还没人说得清。正确做法是纳入配置中心统一管理,配合变更评审。

五、三种"图省事"的反面做法

这三种写法都"能跑",但本质是把本该精细的决策做成了全局默认,最后都会还回来:

  1. 把 Origin 塞进 URL 参数:服务端反射 URL 参数到 ACAO,技术上确实能过浏览器校验,但等于把来源校验彻底作废------任何站点都能过,CORS 从"白名单门禁"退化成了"敞开大门"。
  2. 所有接口一律 * :带凭据接口配 * 直接失效;写接口配 * 等于对全网开放,拆掉了来源防线。
  3. 前端默认 withCredentials: true :所有跨域请求都带 cookie,导致所有接口被迫弃用 *,而且 cookie 无谓暴露、扩大了 CSRF 攻击面。

六、一张落地清单,按需自取

按"需不需要用户信息"对号入座:

  • A. 不需要用户信息 (资讯、官网、纯展示):确认接口公共只读无副作用、请求不带凭据,直接配 *
  • B. 需要用户信息 (后台、会员、个性化):明确鉴权方式;带凭据禁用 *,走反射 Origin + 白名单;cookie 鉴权要有 CSRF 防护(token 或 SameSite),token 鉴权天然抗 CSRF。
  • C. 混合应用 (活动页、留资、抽奖):公共展示部分走 *,写接口走白名单 + 风控,并在接口契约里写清"哪些接口需要身份"。

顺带推荐一个自查方式:把 Chrome 里的请求导出成 HAR(F12 → Network → 右键 "Save all as HAR with content"),写个小脚本逐条核对 ACAO 和凭据是否匹配,比肉眼翻 DevTools 靠谱得多。导出前可以先在 Network 过滤框用 -domain:当前host 排除本域,聚焦跨域请求。

总结

一句话收束:CSRF 防"冒名",CORS 管"跨域",防刷靠"限流 + 风控"------三者各管一段,互不替代。 记住"带凭据禁用 *"这一条核心规则,按接口属性定性、用工具验证,就能避开绝大多数跨域配置的坑。

参考资料

相关推荐
网安蟹佬霸2 小时前
区块链与智能合约安全实战:从Solidity审计到DeFi漏洞深度剖析
运维·前端·网络·安全·自动化·区块链·智能合约
卷无止境2 小时前
Python的contextlib与 FastAPI 中的上下文管理
后端·python·fastapi
念何架构之路2 小时前
Gin响应渲染
前端·javascript·gin
用户938515635072 小时前
从同源策略到Nginx代理:React+NestJS全栈项目跨域实战
后端·docker
invicinble2 小时前
设计网站的底层思路(深刻版本)
前端
卷无止境2 小时前
FastAPI Events 深度解析与工程实践
后端·python·fastapi
FYKJ_20102 小时前
springboot网上购书商城---附源码15749
java·spring boot·后端·python·spark·django·php
三8442 小时前
WordPress REST API 参数校验机制剖析:为什么 author__not_in 无法直接盲注?
服务器·前端·数据库
ITmaster07312 小时前
Vibe Coding 时代:Vue 消失了还是 React 太强?
前端·vue.js·react.js