涵盖从本地开发到线上部署的全场景细节,可直接作为团队技术规范文档。
一、跨域的底层逻辑:浏览器在保护谁?
跨域拦截,拦截的从来不是”请求的发起”,而是”响应的接收”。
同源策略的严格定义
“同源”指的是三个要素完全一致:协议(Protocol) + 域名(Domain) + 端口(Port)。 只要有任何一个不同,浏览器的”同源策略(Same-Origin Policy)“就会生效。
跨域的本质:限制”读”,放任”写”
- 请求其实已经到了后端:发起跨域 GET 或普通 POST 请求时,除非触发了预检(OPTIONS),否则请求一定会到达目标服务器,服务器也会正常执行逻辑(比如写入数据库)。
- 浏览器充当”海关”:浏览器在数据返回时检查响应头。如果没有正确的 CORS 通行证,浏览器会把数据扣留(Network 看到请求成功,但 Console 报 CORS Error,前端代码拿不到数据)。这是为了防止恶意脚本在用户不知情的情况下窃取敏感数据。
二、跨域生命周期细节:简单请求 vs 非简单请求
浏览器根据请求的特征,将其分为两类,处理流程完全不同。
简单请求
触发条件(必须全部满足):
- 方法:
GET、POST、HEAD。 - Header:只能包含 CORS 安全列表字段(
Accept、Accept-Language、Content-Language、Content-Type、Range)。其中Content-Type和Range有额外限制(见下)。 Content-Type只能是:text/plain、multipart/form-data、application/x-www-form-urlencoded。Range只能是单范围值(如bytes=256-或bytes=127-255),不支持多范围。- 请求中的任何
XMLHttpRequestUpload对象均没有注册任何事件监听器,且未使用ReadableStream对象。
执行细节:浏览器不发预检,直接发出真实请求。由于不需要通行证就能直接”触达”后端,如果后端没有专门防范,写操作(POST)极易被恶意第三方网站利用。
非简单请求与 OPTIONS 预检
触发条件:只要不满足简单请求的条件(最常见的是发送 application/json 数据,或者带了自定义 Token 请求头)。
执行细节与性能优化:
- 浏览器会先发送一个
OPTIONS预检请求。如果预检失败,浏览器将拒绝发送接下来的真实请求。 - 性能优化:服务端/网关在响应
OPTIONS时,必须返回Access-Control-Max-Age字段(如设置为86400秒/1天)。这样浏览器会在规定时间内缓存预检结果,后续请求就不再发OPTIONS。
三、全场景跨域解决方案与生产级配置
场景 A:本地开发阶段(Dev Server Proxy)
本地联调时,通常不启动 Nginx。前端框架自带的 Node.js 代理是解决跨域的最佳方案。
原理:前端代码请求本地 Node 服务(同源),Node 服务再把请求转发给测试环境的后端(服务端通信,无浏览器同源策略限制)。
Vite 配置示例 (vite.config.ts):
export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://test-env-backend.com",
changeOrigin: true, // 必须开启,将请求头中的 Origin 修改为目标 URL,避免后端校验失败
rewrite: path => path.replace(/^\/api/, ""), // 路径重写
},
},
},
});
场景 B:生产环境纯 Nginx 代理(网关层管控推荐)
⚠️ 规范铁律:如果决定把跨域交给 Nginx 处理,后端代码中请务必删除所有跨域注解(如 SpringBoot 的
@CrossOrigin),防止 Double CORS 冲突导致浏览器直接报错!
安全隐患警示:网络常见的 add_header Access-Control-Allow-Origin $http_origin; 属于盲返 Origin 漏洞。它等同于允许任意恶意网站跨域携带凭证访问后端,在代码审计与安全扫描中属于高危红线。必须使用 map 建立域名白名单。
生产级 Nginx 配置模板(安全白名单、204预检、5xx报错覆盖):
# 1. 在 nginx.conf 的 http 块中定义允许跨域的域名白名单
map $http_origin $cors_origin {
default "";
"~^https?://(localhost|127\.0\.0\.1)(:\d+)?$" $http_origin; # 本地开发
"~^https://([a-z0-9-]+\.)?your-frontend\.com$" $cors_origin; # 生产环境及子域名
}
server {
listen 80;
server_name www.your-backend.com;
location /api/ {
# 2. 拦截预检请求 (OPTIONS),直接在 Nginx 层面光速响应,不打扰后端
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE" always;
# 根据业务实际补充允许前端携带的自定义 Header
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With, X-CSRF-Token" always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Max-Age 86400 always; # 缓存预检结果1天
return 204;
}
# 3. 正式请求的跨域头附加(always 确保即使后端报错 500/502 也能带上跨域头)
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
# 4. 允许前端 JavaScript 能够读取到的自定义响应头
# 默认前端只能读到 7 个基础响应头,若需读取分页数或 CSRF-Token 必须在此显式暴露
add_header Access-Control-Expose-Headers "X-CSRF-Token, X-Total-Count" always;
# 5. 转发给真实的业务后端
proxy_pass http://127.0.0.1:8080/;
}
}
四、Cookie 跨域与 SameSite 深度排雷
要实现跨域携带 Cookie(如 SSO 单点登录、Session 共享),必须达成严苛的”三方协议”。
前端:主动亮明态度
无论是 Axios 还是 Fetch,默认都会把跨域 Cookie 拦截。前端必须显式开启:
// Axios
axios.get("https://api.b.com/data", { withCredentials: true });
// Fetch API
fetch("https://api.b.com/data", { credentials: "include" });
后端/网关:严苛的 Origin 限制
带凭证(Cookie / Authorization)时的铁律
如果前端开启了凭证携带(withCredentials: true / credentials: 'include'),CORS 响应头有一条铁律:
- ❌
Access-Control-Allow-Origin: *(致命错误,浏览器控制台直接报CORSNotSupportingCredentials错误,拒绝把响应交给 JS) - ✅
Access-Control-Allow-Origin: https://www.your-frontend.com(必须是精确的单域名,或通过 Nginx 的map动态匹配白名单)
带凭证时 * 失效的完整行为:
| 请求类型 | Cookie 到后端? | 前端拿到响应? | 原因 |
|---|---|---|---|
简单请求 + ACAO: * + 凭证 | ✅ 会 | ❌ 被拦 | 请求已发出无法撤回,但浏览器拒绝交出响应 |
非简单请求 + ACAO: * + 凭证 | ❌ 不会 | ❌ 被拦 | 预检就失败,真实请求根本不会发出 |
⚠️ 注意第一行:简单请求场景下,即使浏览器拦截了响应,后端已经收到了 Cookie 并执行了逻辑。CORS 限制的是”读”而非”写”——这是 CSRF 攻击的温床(详见§五)。
不带凭证时 * 能做什么
Access-Control-Allow-Origin: * 并非一无是处,它适用于公开 API、无需身份认证的数据接口:
- ✅ 前端不带凭证的跨域 GET(如公开天气数据、汇率查询)
- ✅ 前端不带凭证的跨域 POST(如匿名表单提交)
- ✅ 第三方开发者从任意域名调用你的公开 API
典型场景:微信小程序 H5 页面调用公开接口、CDN 字体文件跨域加载、开放数据平台的 Public API。
配置选择决策
你的跨域接口需要带 Cookie / 身份凭证吗?
├── 不需要 → Access-Control-Allow-Origin: * 即可(简单省事)
└── 需要 → 必须用白名单方案(见§三场景 B 的 Nginx map 模板)
浏览器:SameSite 终极审判
术语辨析:SameSite 策略基于 Site(eTLD+1,即注册域名),而非 Origin(协议+域名+端口)。例如
https://a.example.com→https://b.example.com是跨 Origin 但不跨 Site,SameSite 策略下不受限制。本文以下”跨站”均指跨 Site。
现代浏览器(Chrome 84+ 全面执行,91+ 移除回退开关)强制执行 SameSite 策略:
- 如果后端
Set-Cookie时未指定该属性,默认为Lax(拒绝跨站 Ajax 请求携带,但允许跨站 GET 顶级导航携带,如用户点击<a href>跳转链接;POST 等非安全方法即使在顶级导航中也被拒绝)。此外,当Lax作为浏览器默认值生效时,存在 2 分钟宽限期:Cookie 设置后 2 分钟内的跨站 POST 顶级导航请求也允许携带,这是为了兼容 SAML 等登录流程。 - 跨域唯一解:后端在生成 Cookie 时,必须写入
SameSite=None; Secure。 - 细节限制:一旦加了
Secure,这颗 Cookie 只能在 HTTPS 协议下传输。如果是本地 HTTP 调试跨域 Cookie,前端和后端必须切换为 HTTPS 本地证书环境(如使用 mkcert 方案)。
五、从 CORS 走向真正的 Web 安全:防御 CSRF 攻击
⚠️ 核心误区纠正:CORS 绝对防不住 CSRF 攻击! 误以为”让接口接收
application/json触发预检,预检不通过就能防住 CSRF”是极其危险的错误认知。
真正危险在于:CSRF 攻击使用的是简单请求(如传统 <form> 表单 POST),简单请求不触发预检,请求直接到达后端。攻击者用 <form> 无法发送 application/json(HTML form 的 enctype 不支持),只能发 application/x-www-form-urlencoded 或 multipart/form-data。
后端是否真的会被攻击,取决于接口写法:
| 接口写法 | form POST 能否触发业务? | 原因 |
|---|---|---|
@RequestBody UserDto | ❌ 415 拒绝 | MappingJackson2HttpMessageConverter 只接受 application/json,form 数据无匹配 Converter |
@RequestBody MultiValueMap | ✅ 可以 | FormHttpMessageConverter 专门处理 application/x-www-form-urlencoded |
UserDto(不加 @RequestBody) | ✅ 可以 | Spring MVC 数据绑定从 request parameters 填充对象,无论 Content-Type |
@RequestParam | ✅ 可以 | 直接读取 query string 或 form 字段 |
结论:@RequestBody + JSON POJO 是天然 CSRF 防护(form 发不了 JSON,Spring 不接受 form 格式)。但不带 @RequestBody 的参数绑定才是真正的攻击面。即便如此,请求仍然到达了后端(只是碰巧返回 415 没执行业务),CORS 保护的是”数据的读”,CSRF 破坏的是”数据的写”——防御应该靠 CSRF Token,而非依赖 Content-Type 校验。
现代企业级 CSRF 防御体系
-
利用现代 Cookie 的
SameSite属性(首选底线防御)对于核心业务 Cookie(如
SESSIONID、Token),在同源或同站业务下,确保将其属性设为SameSite=Lax或SameSite=Strict。这样,用户在第三方恶意网站点击链接或触发跨域 Ajax 时,浏览器绝不会自动带上该 Cookie。 -
签名双提交 Cookie / 自定义 Header 校验(推荐方案)
要求前端在所有发往后端的非简单请求(写操作)中,除了依靠浏览器自动带 Cookie 外,还必须在 Request Header 里手动带上一个随机生成的
X-CSRF-Token(可由后端在登录时下发,并使用 HMAC 签名绑定用户会话)。- 原理:恶意脚本可以利用浏览器机制”自动带上 Cookie”,但受同源策略限制,攻击者在无 XSS 漏洞且域名安全的前提下无法用代码读取跨域 Cookie 并写入自定义 Header。后端网关层比对 Header 中的 Token 与 Cookie 中的 Token 是否一致(签名验证),不一致则直接拒绝请求。
- ⚠️ 必须使用签名版:朴素版(Token 明文存 Cookie + Header)存在已知弱点——攻击者可通过子域名漏洞、DNS 接管或 HTTP Cookie 注入伪造匹配的 Cookie。签名版使用 HMAC 将 Token 与用户会话绑定,可有效防御这些绕过手段。
六、附录:跨域排障 SOP
遇到跨域报错,严禁盲目猜测修改代码,必须按下述步骤查阅浏览器 F12 的 Network 面板:
1. 看颜色和状态码
- 红色
CORS error标注:请求确实被浏览器拦截。继续看响应头。 - 状态码是
404 Not Found或502 Bad Gateway:这不是跨域问题!这是后端服务没启动、或者 Nginx 的proxy_pass路径配错了。请先修复业务路由。
2. 检查 OPTIONS 预检请求
- 有 OPTIONS 且报错:预检没通过。检查 Nginx 是否正确处理了 OPTIONS 方法,是否正确返回了
204或200,以及Allow-Headers是否包含了前端请求带过去的所有自定义头部。 - 没有 OPTIONS 直接报 CORS:当前是”简单请求”被拦截。直接检查真实的 POST/GET 的响应头(Response Headers)。
3. 排查关键响应头(Response Headers)
- 完全没有
Access-Control-Allow-Origin字段? → Nginx 没配对路径,或者白名单map规则没命中。 - 有两个
Access-Control-Allow-Origin字段? → Nginx 和后端服务(如 SpringBoot/Node 源码)里都配了跨域,发生冲突。删掉后端代码里的跨域注解或配置,统一由 Nginx 网关层收口。 - 值是
*但请求又带了 Cookie? → 配置冲突。必须参考”场景B”的 Nginx 模板,将*改为具体的白名单动态变量。 - 网络面板能看到 Header,前端控制台打印却是
undefined? → 未配置Access-Control-Expose-Headers。请在 Nginx 中显式暴露前端需要读取的自定义字段。
更新记录
- 2026-05-26 v1.3: 补充 CSRF 中 form POST 与 JSON API 的关系——说明
<form>无法发送application/json,后端是否被攻击取决于 Content-Type 校验严格程度;即使严格校验返回 415,请求也已到达后端 - 2026-05-26 v1.2: 补充
Access-Control-Allow-Origin: *的适用场景与决策指南——带凭证时*失效的完整行为表(简单请求 Cookie 仍到后端 / 非简单请求预检即拒绝)、不带凭证时*的合法用途、配置选择决策树 - 2026-05-26 v1.1: 事实核查修正——修正预检失败行为的错误描述(非简单请求预检失败不会发送真实请求);补充 Range 为 CORS 安全列表 Header;修正 SameSite 默认 Lax 执行版本为 Chrome 84+;补充 SameSite=Lax 允许 GET 顶级导航及 2 分钟宽限期细节;将”无懈可击方案”降级为签名版推荐方案并说明朴素版弱点;收紧 Nginx map 正则;补充跨站 vs 跨源术语辨析
- 2026-05-26 v1.0: 初版