浏览器跨域
1. 核心概念
1.1 什么是跨域
你一定见过这个报错:
1 | Access to XMLHttpRequest at 'https://api.com/data' |
Postman 能调通,浏览器就不行——问题不在接口,而在浏览器的同源策略(Same-Origin Policy, SOP)。
跨域指的是:网页脚本试图访问与自身协议(scheme)、域名(host)、端口(port)任一不同的资源时,浏览器对这次访问施加的限制。
请求已经发出去了,服务端也已经处理并返回了响应。浏览器拦截的是响应——它不让 JS 读取返回的数据。
类比理解:你住在封闭小区,保安(浏览器)规定只有本小区(同源)的快递可以签收。外面的快递员(跨域请求)想送货进来,必须由物业(服务端)提前开好通行证(CORS 响应头),保安验证通过才放行。
1.2 同源的精确判定
同源要求协议、域名、端口三者完全一致。不是 “ 差不多 “,而是完全一致。
以 https://www.example.com:443/path 为基准:
| 比较 URL | 是否同源 | 原因 |
|---|---|---|
https://www.example.com:443/other | ✅ 同源 | 路径不同不影响 |
http://www.example.com:443 | ❌ 跨域 | 协议不同(http vs https) |
https://www.example.com:8080 | ❌ 跨域 | 端口不同(443 vs 8080) |
https://api.example.com:443 | ❌ 跨域 | 域名不同(子域名也算不同) |
https://example.com:443 | ❌ 跨域 | 域名不同(www vs 裸域) |
https://www.example.com | ✅ 同源 | HTTPS 默认端口就是 443 |
常见误区:
http://a.com请求https://a.com,很多人觉得 “ 域名一样就没事 “。但协议不同(http vs https),端口也不同(80 vs 443),这是跨域。
1.3 同源策略限制了什么
同源策略并非什么都拦,它有明确的限制范围:
| 行为 | 是否受限 | 说明 |
|---|---|---|
<img src="..."> 加载图片 | ❌ 不受限 | 但 JS 无法读取 Canvas 中跨域图片的像素数据 |
<script src="..."> 加载脚本 | ❌ 不受限 | JSONP 正是利用了这一点 |
<link> 加载 CSS | ❌ 不受限 | — |
<iframe> 嵌入页面 | ⚠️ 部分受限 | 可以嵌入,但 JS 无法访问跨域 iframe 的 DOM |
XMLHttpRequest / fetch | ✅ 受限 | 跨域问题最主要的触发场景 |
@font-face 加载字体 | ✅ 受限 | 跨域字体必须有 CORS 头 |
localStorage / Cookie 读取 | ✅ 受限 | 只能访问同源的存储 |
1.4 核心术语速查
| 术语 | 一句话解释 | 类比 |
|---|---|---|
| Same-Origin Policy (SOP) | 协议 + 域名 + 端口必须一致 | 同一小区住户 |
| CORS | 服务端声明 “ 谁有权限访问我 “ | 物业开通行证 |
| Preflight Request | 复杂请求前的 OPTIONS 试探 | 先打电话确认能不能送 |
| Origin | 请求头中标识来源的字段 | 快递单上的寄件地址 |
Access-Control-Allow-Origin | 服务端声明允许的来源 | 通行证上写的小区名 |
Access-Control-Max-Age | 预检结果的缓存时间 | 通行证的有效期 |
| Simple Request | 满足特定条件、无需预检的请求 | 挂号信可以直接投递 |
| CORB | 浏览器阻止跨域响应进入渲染进程 | 保安直接把可疑包裹退回 |
理解了这些基础概念,接下来看浏览器内部到底怎么处理一次跨域请求。
2. 浏览器内部处理流程
2.1 从 fetch() 到网络层:一次跨域请求的完整旅程
在 JS 中调用 fetch('https://api.com/data') 时,浏览器内部经历以下流程:
%%{init: {'theme': 'base', 'themeVariables': {'actorBkg': '#3B82F6', 'actorTextColor': '#1E3A5F', 'actorBorder': '#2563EB', 'signalColor': '#60A5FA', 'activationBkgColor': '#DBEAFE', 'activationBorderColor': '#3B82F6'}}}%%
sequenceDiagram
autonumber
participant JS as "JS 引擎 (V8)"
participant Blink as "渲染引擎 (Blink)"
participant NP as "网络进程 (Network Service)"
participant Server as "目标服务器"
JS->>Blink: "fetch('https://api.com/data')"
activate Blink
Blink->>Blink: "检查 Origin 与目标是否同源"
Note over Blink: "不同源 → 进入 CORS 流程"
Blink->>Blink: "判断是否简单请求"
alt 需要预检
Blink->>NP: "发送 OPTIONS 预检请求"
activate NP
NP->>Server: "OPTIONS /data"
Server-->>NP: "204 + CORS 响应头"
NP-->>Blink: "预检响应"
deactivate NP
Note over Blink: "校验 CORS 头是否允许"
end
Blink->>NP: "发送正式请求 + Origin 头"
activate NP
NP->>Server: "GET /data + Origin: https://app.com"
Server-->>NP: "200 + 数据 + CORS 头"
NP-->>Blink: "响应数据"
deactivate NP
Blink->>Blink: "校验 Access-Control-Allow-Origin"
alt 校验通过
Blink-->>JS: "返回 Response 对象"
else 校验失败
Blink-->>JS: "抛出 TypeError,响应体置空"
end
deactivate Blink三个关键点:
- CORS 校验在渲染进程(Blink)完成,不在网络层
- 网络进程只负责收发数据,不做 CORS 判断
- 服务端已经收到请求并处理完毕,校验失败只是浏览器不把响应交给 JS
2.2 简单请求的判定条件
浏览器将同时满足以下全部条件的请求视为 “ 简单请求 “,无需发送预检:
五个条件:
- 方法限于
GET、HEAD、POST - 请求头仅包含安全字段:
Accept、Accept-Language、Content-Language、Content-Type(值有额外限制)、Range(仅简单 range 值) Content-Type仅限application/x-www-form-urlencoded、multipart/form-data、text/plain- 请求中没有使用
ReadableStream对象 XMLHttpRequest对象没有注册 upload 事件监听
实际开发中,只要用了
Content-Type: application/json或自定义 Header(如Authorization),就一定是非简单请求,一定会触发预检。
2.3 SOP、CORS、CORB 的区别
这三个概念容易混淆,用一张表理清:
| 维度 | SOP(同源策略) | CORS | CORB |
|---|---|---|---|
| 定义 | 浏览器的基础安全模型 | 放宽 SOP 的标准机制 | 阻止跨域响应进入渲染进程 |
| 实现层 | 渲染引擎 (Blink) | 渲染引擎 (Blink) | 网络进程 (Network Service) |
| 作用时机 | JS 尝试跨域操作时 | 收到跨域响应后 | 响应到达渲染进程之前 |
| 保护目标 | 防止 JS 读取跨域数据 | 允许授权的跨域访问 | 防止 Spectre 等侧信道攻击 |
| 核心逻辑 | 拒绝一切跨域 DOM / 数据访问 | 检查 Access-Control-* 头 | 按 MIME 类型拦截(HTML/JSON/XML) |
| 失败表现 | JS 报错 | fetch 返回 opaque response | 响应体被清空,Network 面板显示正常 |
CORB 是针对 Spectre 漏洞的额外防线。即使没有 CORS 头,<img src="api/secret.json"> 这种请求虽然会发出,但 CORB 会在网络层直接清空 JSON 响应体,防止恶意脚本通过侧信道嗅探内存中的数据。
理解了浏览器内部机制,接下来看生产环境中如何正确配置跨域。
3. 生产环境最佳实践
3.1 Nginx 反向代理(推荐方案)
在生产环境中,最干净的做法是通过 Nginx 将前端和后端统一到同一域名下,从架构层面消灭跨域:
1 | server { |
前端请求 https://app.example.com/api/users,Nginx 转发到后端 http://127.0.0.1:8080/users。浏览器看到的始终是同域请求,跨域问题根本不存在。
如果前后端必须分域,在 Nginx 中统一添加 CORS 头:
1 | location /api/ { |
always关键字很重要:默认add_header只在 2xx/3xx 响应中生效。加了always,即使后端返回 4xx/5xx 也会带上 CORS 头,避免 “ 正常请求有 CORS 头、报错时没有 “ 的坑。
3.2 Go Gin CORS 中间件
3.2.1 基础配置
1 | import "github.com/gin-contrib/cors" |
各配置项的含义:
| 配置项 | 作用 | 对应响应头 |
|---|---|---|
AllowOrigins | 允许哪些域名访问 | Access-Control-Allow-Origin |
AllowMethods | 允许哪些 HTTP 方法 | Access-Control-Allow-Methods |
AllowHeaders | 允许前端发送哪些请求头 | Access-Control-Allow-Headers |
ExposeHeaders | 允许前端 JS 读取哪些响应头 | Access-Control-Expose-Headers |
AllowCredentials | 是否允许携带 Cookie | Access-Control-Allow-Credentials |
MaxAge | 预检结果缓存时间 | Access-Control-Max-Age |
3.2.2 多域名动态匹配
当有多个前端域名时,不能用 *(因为 AllowCredentials: true),可以用 AllowOriginFunc 动态判断:
1 | engine.Use(cors.New(cors.Config{ |
3.2.3 ExposeHeaders 详解
默认情况下,跨域响应中前端 JS 只能读取 7 个安全响应头:Cache-Control、Content-Language、Content-Length、Content-Type、Expires、Last-Modified、Pragma。
其他响应头(即使存在于响应中)前端 JS 一律读不到:
1 | const res = await fetch('https://api.com/data'); |
只有通过 ExposeHeaders 声明后才可读取。如果前端无需读取自定义响应头,此项留空即可。
3.2.4 AllowOrigins: ["*"] + AllowCredentials: true 的陷阱
CORS 规范明确规定:当 Access-Control-Allow-Credentials: true 时,Access-Control-Allow-Origin 不能是 *。
Gin 的 cors 中间件内部做了兼容处理——将 * 自动替换为请求中的 Origin 值。但这等同于信任所有来源,存在安全风险。生产环境务必指定具体域名或使用 AllowOriginFunc。
3.3 开发环境代理方案
3.3.1 Vite
1 | // vite.config.js |
前端请求 /api/users → Vite 开发服务器转发到 https://api.example.com/users。浏览器看到的是同域请求,跨域被绕过。
3.3.2 Webpack (Create React App)
1 | // src/setupProxy.js |
代理方案的本质:浏览器 → 同域的开发服务器 → 转发到跨域的后端。服务端之间不存在跨域限制,所以问题被绕过了。
3.4 Cookie 与凭证的跨域传递
跨域请求默认不携带 Cookie。要携带需要前后端同时配置:
前端:
1 | // fetch |
后端(必须同时满足):
1 | Access-Control-Allow-Origin: https://app.com # 不能是 * |
还需关注 Cookie 的 SameSite 属性:
| SameSite 值 | 跨域请求是否携带 Cookie | 说明 |
|---|---|---|
None | ✅ 携带 | 必须同时设置 Secure(仅 HTTPS) |
Lax(浏览器默认值) | ⚠️ 部分携带 | 仅顶级导航的 GET 请求携带 |
Strict | ❌ 不携带 | 任何跨站请求都不带 |
Chrome 80+ 开始默认
SameSite=Lax。如果跨域接口依赖 Cookie,必须显式将 Cookie 设为SameSite=None; Secure。
4. 跨域解决方案
4.1 CORS(标准方案,首选)
CORS(Cross-Origin Resource Sharing)是 W3C 标准,通过服务端在响应头中声明权限来实现跨域访问控制。
完整 CORS 响应头一览:
| 响应头 | 作用 | 示例值 |
|---|---|---|
Access-Control-Allow-Origin | 允许的来源 | https://app.com 或 * |
Access-Control-Allow-Methods | 允许的 HTTP 方法 | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | 允许的请求头 | Authorization, Content-Type |
Access-Control-Expose-Headers | 允许前端 JS 读取的响应头 | X-Request-Id, X-Total-Count |
Access-Control-Allow-Credentials | 是否允许携带凭证 | true |
Access-Control-Max-Age | 预检缓存秒数 | 7200 |
4.2 反向代理
原理:让浏览器只跟同域的代理服务器通信,由代理转发请求到真实后端。服务端之间不存在跨域限制。
1 | 浏览器 → https://app.com/api/users(同域,无跨域) |
适用场景:生产环境同域部署、开发环境 Vite/Webpack proxy。
4.3 postMessage(跨窗口通信)
当两个不同源的页面需要通信时(如主页面与 iframe、窗口与弹出窗口),使用 window.postMessage:
1 | // 发送方(https://app.com) |
安全要点:必须校验
event.origin。用*作为 targetOrigin 或不校验来源,等于把大门敞开。
4.4 WebSocket
WebSocket 协议不受同源策略限制,握手阶段使用 HTTP,但一旦升级为 WebSocket 连接,浏览器不再执行 CORS 检查。
1 | // 可以直接连接跨域的 WebSocket 服务 |
但这不意味着 WebSocket 没有安全风险:
- 服务端应校验
Origin头,防止恶意网站建立连接 - 使用
wss://(加密)而非ws:// - 连接建立后应自行实现鉴权机制
4.5 跨域方案对比
| 维度 | CORS | 反向代理 | postMessage | WebSocket | JSONP |
|---|---|---|---|---|---|
| 原理 | 服务端设置响应头 | 代理转发到同域 | 窗口间消息传递 | 独立协议,不受 SOP | script 标签无跨域限制 |
| 支持方法 | 全部 HTTP 方法 | 全部 HTTP 方法 | N/A | 双向通信 | 仅 GET |
| 安全性 | 高,可精细控制 | 高 | 中,需校验 origin | 中,需服务端校验 | 低,XSS 风险 |
| 适用场景 | API 跨域(标准方案) | 同域部署 / 开发环境 | iframe / 弹窗通信 | 实时通信 | 已过时,仅兼容旧系统 |
| 浏览器兼容 | IE10+ | 无限制 | IE8+ | IE10+ | 全部 |
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart TD
A(["需要跨域通信"]) --> B{"通信场景?"}
B -->|"前端调后端 API"| C{"能否统一域名?"}
C -->|"可以"| D["反向代理 ✅"]
C -->|"不行"| E["CORS ✅"]
B -->|"iframe / 弹窗通信"| F["postMessage ✅"]
B -->|"实时双向通信"| G["WebSocket ✅"]
B -->|"兼容远古浏览器"| H["JSONP(不推荐)"]
classDef start fill:#3B82F6,stroke:#2563EB,color:#fff,stroke-width:2px
classDef success fill:#10B981,stroke:#059669,color:#fff
classDef warning fill:#F59E0B,stroke:#D97706,color:#1E3A5F
classDef decision fill:#DBEAFE,stroke:#3B82F6,color:#1E3A5F
class A start
class D,E,F,G success
class H warning
class B,C decision5. 同源策略的设计演进
5.1 1995:诞生
同源策略由 Netscape Navigator 2.0(1995 年)引入,最初设计者是 Brendan Eich 团队。当时的目标:防止一个页面中的脚本读取另一个页面的内容。
那时 Web 才刚开始有 “ 交互 “ 的概念。Netscape 意识到,如果不加限制,一个恶意页面可以通过 <iframe> 嵌入银行网站,然后用 JS 读取 iframe 中的 DOM——包括账户余额、转账表单。
5.2 从 “ 全部禁止 “ 到 “ 有条件放行 “
%%{init: {'theme': 'base', 'themeVariables': {'cScale0': '#3B82F6', 'cScale1': '#10B981', 'cScale2': '#F59E0B', 'cScale3': '#EF4444'}}}%%
timeline
title 跨域安全机制演进
section 1995-2004
1995 : Netscape 引入 SOP : 奠定 Web 安全基石
1999 : IE5 引入 XMLHttpRequest : AJAX 出现,跨域需求激增
2004 : W3C 开始制定 CORS 草案 : 标准化跨域解决方案
section 2005-2014
2005-2008 : JSONP 流行 : hack 方案,利用 script 标签不受限
2014 : CORS 成为 W3C 正式推荐标准 : 取代 JSONP 成为正统方案
section 2018-2020
2018 : Chrome 实施 Site Isolation : Spectre 漏洞推动更严格的进程隔离
2019 : CORB 默认启用 : 在网络层增加一道防线
2020 : SameSite Cookie 默认 Lax : Chrome 80+ 收紧跨站 Cookie5.3 为什么是浏览器限制,而非服务端限制
这是面试高频问题。核心逻辑:
- 服务端无法区分 “ 用户主动访问 “ 和 “ 恶意脚本自动请求 “——两者的 HTTP 请求在技术上完全一样
- 浏览器能区分——浏览器知道发起请求的脚本来自哪个 Origin
- 保护的是用户,不是服务端——服务端自己的数据本来就在自己手里,不需要保护。需要保护的是正在浏览器中登录银行的用户,防止恶意脚本代替用户操作
所以 curl、Postman、服务端 HTTP 客户端永远不会遇到跨域——它们不是浏览器,不需要保护 “ 正在使用的用户 “。
6. 安全视角
6.1 如果没有同源策略
假设同源策略不存在,一个攻击场景:
%%{init: {'theme': 'base', 'themeVariables': {'actorBkg': '#3B82F6', 'actorTextColor': '#1E3A5F', 'actorBorder': '#2563EB', 'signalColor': '#60A5FA', 'activationBkgColor': '#DBEAFE', 'activationBorderColor': '#3B82F6'}}}%%
sequenceDiagram
autonumber
participant User as "用户"
participant Evil as "evil.com"
participant Bank as "bank.com"
User->>Bank: "登录银行,获得 Session Cookie"
User->>Evil: "点击恶意链接,打开 evil.com"
Evil->>Evil: "执行恶意 JS 代码"
Evil->>Bank: "fetch('/api/balance', {credentials: 'include'})"
Note over Evil,Bank: "浏览器自动带上 bank.com 的 Cookie"
Bank-->>Evil: "返回余额数据(无 SOP 时可读取)"
Evil->>Evil: "窃取用户余额、交易记录"
Evil->>Bank: "发起转账请求"
Note over Evil: "用户资产被盗"有了同源策略,第 6 步被阻断——浏览器不让 evil.com 的 JS 读取 bank.com 的响应。
6.2 CSRF:同源策略挡不住的攻击
同源策略只阻止读取响应,不阻止发送请求。CSRF(Cross-Site Request Forgery)利用了这一点——攻击者不需要读取响应,只需要让请求发出去就够了。
比如一个简单的 <img src="https://bank.com/api/transfer?to=hacker&amount=10000">,浏览器会自动发送这个 GET 请求并带上 Cookie。服务端以为是用户操作,就执行了转账。
防御 CSRF 的手段:
- CSRF Token:服务端生成随机 Token,表单提交时校验
- SameSite Cookie:设为
Strict或Lax,阻止跨站自动携带 - 检查
Origin/Referer头:服务端验证请求来源
6.3 XSS:让同源策略形同虚设
如果攻击者通过 XSS(Cross-Site Scripting)在目标页面注入了恶意脚本,那么这段脚本已经是同源的了——同源策略完全不起作用。
1 | 存储型 XSS:攻击者在 bank.com 的评论区注入 <script>, |
这就是 XSS 被视为最危险的 Web 漏洞之一的原因——它直接绕过了同源策略这个最基础的防线。
防御 XSS 的手段:
- 输入过滤 + 输出转义:所有用户输入都不信任
- Content-Security-Policy (CSP):限制页面可以加载和执行的资源来源
- HttpOnly Cookie:阻止 JS 读取敏感 Cookie
7. 实战
7.1 问答题
Q1:http://a.com 请求 https://a.com/api 会跨域吗?
会。协议不同(http vs https),端口也不同(80 vs 443)。域名相同不代表同源。
Q2:请求带了 Authorization: Bearer xxx,这是简单请求吗?
不是。Authorization 不在 CORS 安全请求头列表中。浏览器会先发 OPTIONS 预检。
Q3:为什么 Postman 不会遇到跨域?
跨域是浏览器的同源策略行为。Postman 是独立的 HTTP 客户端,不执行同源策略,所以没有跨域限制。
Q4:CORS 预检请求可以减少吗?
可以。设置 Access-Control-Max-Age 缓存预检结果。Chrome 上限 7200 秒,Firefox 上限 86400 秒。在缓存有效期内,相同请求不会重复预检。
Q5:Access-Control-Allow-Origin 可以设置多个域名吗?
不可以。这个响应头只能是单个域名或 *。要支持多域名,需要在服务端动态判断请求的 Origin 头,匹配后返回对应域名。
Q6:跨域请求,服务端到底收没收到?
收到了。跨域请求是正常的 HTTP 请求,服务端会接收并处理。浏览器只是拦截了 JS 读取响应。用服务端日志或抓包工具可以验证请求确实到达了。
Q7:<img> 可以加载跨域图片,为什么 Canvas 中的跨域图片会 “ 污染 “?
<img> 加载跨域图片是被允许的(展示层)。但如果将跨域图片绘制到 Canvas 中,然后用 canvas.toDataURL() 读取像素数据,浏览器会阻止——因为这属于 “ 通过脚本读取跨域数据 “。解决方案是图片服务设置 Access-Control-Allow-Origin,并在 <img> 标签加上 crossorigin="anonymous"。
Q8:CORS 和 CORB 有什么区别?
CORS 在渲染进程中检查响应头,决定 JS 能否读取响应。CORB 在网络进程中检查 MIME 类型,直接阻止可疑的跨域响应(如 JSON、HTML)进入渲染进程的内存。CORB 是防 Spectre 侧信道攻击的额外防线。
7.2 真实世界案例
- Nginx 反向代理消灭跨域:美团、字节等公司的 BFF 层用 Nginx 将前端和 API 服务统一到同一域名下,从架构层面彻底消除跨域。
- 微前端的跨域治理:蚂蚁金服的 qiankun 框架在加载子应用时,需要子应用的静态资源配置
Access-Control-Allow-Origin,否则主应用无法 fetch 子应用的 HTML/JS。 - CDN 字体跨域:Google Fonts 等 CDN 必须设置
Access-Control-Allow-Origin: *,因为@font-face加载字体文件受同源策略约束。自建字体 CDN 如果忘了配这个头,字体会加载失败但没有明显报错。 - 第三方登录的 CORS 困境:OAuth 回调通常使用重定向(302)而非 AJAX,部分原因就是为了避开 CORS 限制。如果用 AJAX 去请求 OAuth 授权页面,由于第三方不会给你配 CORS 头,请求必然失败。