跨域(CORS)深度解析

引言

这篇文章不打算罗列一堆 "跨域解决办法" 的八股,而是从同源策略的设计动机讲起,把 CORS 每一个响应头在协议层到底干嘛、浏览器在哪个环节拦了你的响应等一次性说清楚。读完你会发现,跨域不是一个 "配一下就好" 的配置项,而是理解浏览器安全模型的一把钥匙。

一、先纠正一个最常见的误解:跨域不是 "请求发不出去"

很多同学第一次碰到跨域报错时,看到控制台那行红色的:

复制代码
Access to fetch at 'https://api.xxx.com/user' from origin 'https://www.xxx.com'

has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present

on the requested resource.

第一反应是:"完了,请求没发出去,被浏览器挡在门口了。"

这是错的。

真相是:请求已经正常发到服务器了,服务器也正常返回了数据,是浏览器拿到响应之后,发现响应头里没有 Access-Control-Allow-Origin 或者这个源不在白名单里,于是把响应 "扣下" 了,不交给 JS。

你可以打开 Chrome DevTools 的 Network 面板,点开那个红色的请求,会看到:

  • Status Code 是 200 / 201 / 500,甚至响应体里都有数据;
  • 但 Console 里报 CORS 错误。

为什么浏览器要这么设计?这就是同源策略的核心目的 ------它防的不是 "服务器被请求",而是 "恶意网站拿到你的数据" 。服务器本来就该对任何来者不拒地响应(否则 CDN、公共 API 就没法用了),浏览器的责任是:当一个恶意页面(比如 evil.com)用你登录态去请求 bank.com 时,服务器哪怕返回了你的余额,浏览器也不能让 evil.com 的 JS 读到这个响应。

理解这一点,后面所有机制都顺了。


二、同源策略:跨域问题的根源

2.1 什么是 "源"(Origin)

同源 = 协议 + 域名 + 端口三者完全一致,任何一个不同就算跨域。

以 https://www.example.com:443/a/page.html 为例:

|------------------------------------------|------|--------------------|
| URL | 是否同源 | 原因 |
| https://www.example.com/b/page.html | 同源 | 只有路径不同 |
| http://www.example.com/a/page.html | 跨域 | 协议不同(https → http) |
| https://mail.example.com/a/page.html | 跨域 | 子域名不同 |
| https://www.example.com:8080/a/page.html | 跨域 | 端口不同 |
| https://example.com/a/page.html | 跨域 | 主机名不同(www 算不同源) |

注意几个细节:

  • IE 是个例外:IE 在同源判断上不把端口算进去(历史 bug),所以老系统里经常出现 "IE 不跨域、Chrome 跨域" 的诡异现象。
  • about:blank、data: URL 这种 "空源" 在调试时也容易踩坑。

2.2 同源策略到底限制了什么

很多人以为同源策略就是 "不能发 AJAX 请求",其实它限制的范围要广得多:

  1. DOM 访问:一个窗口 /iframe 里的 JS 不能读写另一个源的 document、cookie、localStorage。
  2. 网络响应读取:XMLHttpRequest / fetch 拿到跨域响应后,JS 读不到 response body。
  3. Cookie / LocalStorage / IndexedDB :按源隔离,a.com 的 JS 读不到 b.com 的存储。
  4. 某些 HTTP 头:比如 Authorization、Cookie 在跨域场景下的自动携带规则。

但是 ------以下这些东西是不受同源策略限制的(或者说浏览器主动放行):

  • <script src="..."> 加载脚本(这就是 JSONP 的原理)
  • <link rel="stylesheet"> 加载样式
  • <img>、<video>、<audio> 加载媒体
  • <iframe> 加载页面(能加载,但不能读里面的 DOM)
  • <form action="..."> 提交表单(能提交,但拿不到响应)
  • 字体 @font-face(这个历史上曾经跨域,需要 CORS 头)
  • Web Worker、Service Worker 的脚本加载

这张表非常重要:跨域方案的本质,要么是利用 "哪些标签本来就能跨域加载"(script/img/form),要么是 "服务器主动说我允许这个源读响应"(CORS),要么是 "让浏览器看起来是同源"(Nginx 代理)。

2.3 为什么浏览器要设计同源策略

假想一下没有同源策略的世界:

  • 服务器返回了你的邮件列表;
  • evil.com 把邮件内容发到自己的服务器。

这就是经典的 CSRF(跨站请求伪造)+ 数据窃取 攻击链。同源策略就是为了斩断最后一步 ------让 evil.com的 JS 读不到 mail.example.com的响应。

所以同源策略的本质是:浏览器作为用户的代理人,替你挡住 "恶意站点读你的数据",而不是 "挡住服务器响应请求"。理解这个动机,才能理解为什么 CORS 是一个 "服务器白名单 + 浏览器放行" 的机制,而不是别的形态。


三、CORS:官方的跨域解法

CORS(Cross-Origin Resource Sharing,跨源资源共享)是 W3C 在 2014 年正式定稿的标准(RFC 6454 / Fetch Living Standard),它的核心思路是:让服务器通过 HTTP 响应头显式告诉浏览器 "我允许哪些源来读我的响应",浏览器看到白名单后就放行。

3.1 CORS 的核心思想

整个 CORS 流程可以浓缩成一句话:

浏览器在发跨域请求时自动带上 Origin 请求头,服务器在响应头里回 Access-Control-Allow-Origin,浏览器对比两者,决定放不放行。

它解决的是 "读响应" 的问题,不是 "发请求" 的问题。

3.2 请求头与响应头全景

浏览器自动加的请求头(你不用手动 set):

|-------------------------------------------------------|----------------------|
| 请求头 | 含义 |
| Origin: https://www.example.com | 请求来自哪个源(仅跨域时加) |
| Access-Control-Request-Method: POST | 预检时告诉服务器实际请求要用什么方法 |
| Access-Control-Request-Headers: Content-Type, X-Token | 预检时告诉服务器实际请求会带哪些自定义头 |

服务器需要回的响应头:

|----------------------------------|-------------------------|--------------------------------------|
| 响应头 | 含义 | 示例 |
| Access-Control-Allow-Origin | 允许的源 | https://www.example.com 或 * |
| Access-Control-Allow-Methods | 允许的方法 | GET, POST, PUT, DELETE |
| Access-Control-Allow-Headers | 允许的自定义请求头 | Content-Type, X-Token, Authorization |
| Access-Control-Allow-Credentials | 是否允许携带凭证 | true |
| Access-Control-Max-Age | 预检结果缓存秒数 | 86400 |
| Access-Control-Expose-Headers | 允许 JS 读取的响应头 | Content-Range, X-Total-Count |
| Vary: Origin | 告诉缓存层 "响应内容随 Origin 变化" | 必加 |

3.3 简单请求(Simple Request)

CORS 把跨域请求分成两类,简单请求不发预检,直接发实际请求。

一个请求同时满足以下所有 条件,才算简单请求 "[https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS "https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS")":

  1. 方法只能是 GET、HEAD、POST 三者之一;
  2. 手动设置的请求头只能是以下集合:
  • Accept、Accept-Language、Content-Language
  • Content-Type(且有严格限制,见下)
  • Range(有特定语法限制)
  • 以及 UA 自动加的那些(Connection、User-Agent、Host 等)
  1. Content-Type 只能是以下三者之一:
  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain
  1. 请求中没有使用 ReadableStream,没有注册任何事件监听器。

3.4 预检请求(Preflighted Request)

不满足简单请求条件的,都叫 "非简单请求",浏览器会先自动发一个 OPTIONS 方法的预检请求,问服务器:" 我接下来要发一个 POST + Content-Type: application/json + X-Token: xxx 的请求,你允许吗?"

预检请求长这样:

复制代码
OPTIONS /api/user HTTP/1.1

Host: api.example.com

Origin: https://www.example.com

Access-Control-Request-Method: POST

Access-Control-Request-Headers: Content-Type, X-Token

服务器如果同意,回:

复制代码
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, X-Token, Authorization
Access-Control-Max-Age: 86400

浏览器收到后,才真正发业务请求:

复制代码
POST /api/user HTTP/1.1

Host: api.example.com

Origin: https://www.example.com

Content-Type: application/json

X-Token: xxx

{"name": "小明"}

实际请求的响应也必须带 Access-Control-Allow-Origin,否则一样被拦。

为什么要有预检? 因为在 CORS 出现之前,<form> 就能跨域 POST(表单提交不受同源策略限制),服务器早就习惯了 "任何站都能来 POST"。如果浏览器允许 JS 自由发 application/json 的 POST,恶意网站就能借用户登录态悄悄改数据。

预检的本质是:在发 "可能修改服务器数据" 的请求之前,先让服务器明确表态 "我知道这个源要来,我同意"。

几个实操要点:

  • 预检请求本身不带 Cookie(规范明确规定),所以你后端不要在 OPTIONS 接口上做鉴权,否则一定 401。
  • 预检响应建议直接返回 204 No Content,不要返回业务数据。
  • 服务器必须正确响应 OPTIONS 方法,不能让框架把它当 404 处理。

3.5 携带凭证的请求(Credentials)

默认情况下,跨域 fetch / XHR 不会带 Cookie,也不会带客户端证书、Authorization 头。如果业务需要带 Cookie(比如登录态),必须前端显式声明:

复制代码
// fetch
fetch('https://api.example.com/user', {
credentials: 'include' // 关键
})
// axios
axios.get('https://api.example.com/user', {
withCredentials: true // 关键
})
// XHR
xhr.withCredentials = true

同时,服务器必须回:

复制代码
Access-Control-Allow-Credentials: true

Access-Control-Allow-Origin: https://www.example.com // 注意:不能是 *

这里有一个 HTTP 规范层面的硬约束 :当请求带凭证时,Access-Control-Allow-Origin 绝对不能是 *,必须是具体的源(通常做法是服务器读请求里的 Origin 头,反射回去)。如果配了 *,浏览器会直接拒绝并报错:

复制代码
The value of the 'Access-Control-Allow-Origin' header in the response

must not be the wildcard '*' when the request's credentials mode is 'include'.

为什么要这么规定? 因为如果允许 * + Cookie,那任何恶意网站都能借你的登录态读你的数据,同源策略就形同虚设了。

3.6 暴露响应头:Access-Control-Expose-Headers

默认情况下,JS 只能读到这几个 "安全列表" 里的响应头:

  • Cache-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

如果你前端想读 X-Total-Count(分页)、Content-Range(断点续传)、X-Request-Id(链路追踪)等自定义头,必须在响应里加:

复制代码
Access-Control-Expose-Headers: X-Total-Count, Content-Range, X-Request-Id

这是一个极其高频的坑:很多同学说 "我跨域通了,但是 response.headers 里拿不到我的自定义头",99% 是漏了这个。

3.7 预检缓存:Access-Control-Max-Age

每次非简单请求都发一次 OPTIONS,开销不小(多一个 RTT)。服务器可以在预检响应里加:

复制代码
Access-Control-Max-Age: 86400

表示这个预检结果浏览器缓存 86400 秒(1 天),期间对同样的方法 + 头的跨域请求不再发 OPTIONS。

注意:

  • Chrome 硬性把这个值上限钳制在 600 秒 (Firefox 是 86400),你写 86400 在 Chrome 里也只会按 600 算 "[Cross-Origin Resource Sharing (CORS) - HTTP \| MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS "Cross-Origin Resource Sharing (CORS) - HTTP | MDN")"。
  • 缓存是按 "源 + 方法 + 实际请求头" 作为 key 的,改一个自定义头就会重新预检。

四、六种跨域方案横向对比

4.1 JSONP

原理:利用 <script> 标签不受同源策略限制的特性。

复制代码
// 前端

function handleData(data) { console.log(data) }

const script = document.createElement('script')

script.src = 'https://api.example.com/user?callback=handleData'

document.body.appendChild(script)

// 后端返回(注意是 JS,不是 JSON)

handleData({ name: '小明', age: 22 })

优点:

  • 兼容性极好,连 IE6 都能用;
  • 实现简单。

缺点:

  • 只支持 GET(因为 script 标签只能 GET);
  • 有安全风险:如果服务器返回的内容被劫持,等于在你的域里执行任意 JS(XSS 级);
  • 错误处理困难:script 加载失败只能监听 onerror,拿不到 HTTP 状态码;
  • 已经被 CORS 全面取代,新项目不要再用。

4.2 CORS(后端方案)

就是上面讲的那一套,现代浏览器标准做法。

  • 优点:标准、功能全、支持所有方法、有预检机制保护数据;
  • 缺点:需要后端配合,老 IE(IE9 以下)支持不全。

4.3 Nginx 反向代理

原理:浏览器只跟 Nginx 通信,Nginx 再把请求转发给后端服务。因为浏览器看到的是 " 我请求的是同源的 /api",所以根本不存在跨域。

复制代码
server {
    listen 80;
    server_name www.example.com;

    location / {
        root /usr/share/nginx/html;
    }

    # 关键:/api 开头的请求转发给后端
    location /api/ {
        proxy_pass http://backend.internal:8080;
        proxy_set_header Host $host;
        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;
    }
}

前端代码里请求 /api/user 就行了,浏览器认为这是同源的。

优点:

  • 前端零改动;
  • 隐藏后端真实地址,安全;
  • 顺带做负载均衡、限流、HTTPS 终止。

缺点:

  • 部署上需要有一层 Nginx(或网关);
  • WebSocket、SSE 这种长连接需要额外配置 Upgrade 头。

注意:很多同学把 "Nginx 配 CORS 头" 和 "Nginx 反向代理" 混为一谈。前者是 Nginx 帮后端加 Access-Control-Allow-Origin(浏览器仍然跨域),后者是 Nginx 把请求转发到同源路径(浏览器根本不跨域)。两者原理完全不同。

4.4 postMessage(跨文档通信)

当你需要在两个窗口 /iframe 之间传数据时(比如父页面 a.com 嵌入 b.com 的 iframe),用 postMessage:

复制代码
// 父窗口 a.com 向 iframe b.com 发消息

iframe.contentWindow.postMessage({ type: 'GREET' }, 'https://b.com')

// b.com 接收

window.addEventListener('message', (e) => {

if (e.origin !== 'https://a.com') return // 必须校验 origin!

console.log(e.data)

e.source.postMessage('hello', e.origin)

})

优点:W3C 标准、安全(必须校验 origin)、支持任何窗口间通信;

缺点:只解决 "文档间通信",不能解决 "fetch 调 API"。

安全提醒 :接收方必须校验 e.origin,否则等于在你的域里接收任意网站的消息,又是一个 XSS 入口。

4.5 document.domain(同主域子域)

a.example.com 和 b.example.com 之间,如果都把 document.domain 设成 example.com,就可以互相访问 DOM。

复制代码
// 两个页面都执行

document.domain = 'example.com'

适用场景极窄 :只适用于同主域不同子域 、且通过 iframe 嵌入的老系统。现代浏览器已经在逐步废弃这种方式(ES 规范里 document.domain 的可写性在收紧)。新项目别用。

4.6 WebSocket

WebSocket 的握手是 HTTP,但一旦升级成功,就是一个独立的 TCP 连接,不受同源策略限制。

复制代码
const ws = new WebSocket('wss://api.example.com/chat')

服务器需要在握手时校验 Origin 头(因为浏览器握手会带 Origin),拒绝不信任的源。

优点:全双工、长连接;

缺点:不是用来替代 REST API 的,只适合推送 / 实时场景。

4.7 选型对照表

|-----------------|----------------|----------|---------------|-------|
| 方案 | 适用场景 | 是否需要后端配合 | 安全性 | 现代推荐度 |
| JSONP | 老系统兼容 | 是 | 低 | ★ |
| CORS | 前后端分离 API 调用 | 是 | 高 | ★★★★★ |
| Nginx 代理 | 部署层面统一解决 | 否(前端无感) | 高 | ★★★★★ |
| postMessage | iframe / 新窗口通信 | 是 | 高(需校验 origin) | ★★★★ |
| document.domain | 同主域子域 iframe | 否 | 中(正在废弃) | ★★ |
| WebSocket | 实时通信 | 是 | 中(需校验 Origin) | ★★★★ |


五、底层思考:为什么 CORS 是 "浏览器拦截响应" 而不是 "拦截请求"

这是理解 CORS 本质的终极问题。

如果浏览器真的拦截了跨域请求,会发生什么?

  • <form> 提交跨域表单会失效(但这是 90 年代就有的合法功能);
  • <img src="...">、<video> 全部失效;
  • 服务器根本不知道自己被谁请求了,CSRF 攻击反而更隐蔽 ------ 因为恶意请求已经发出去了,服务器照样执行了转账、改密码,只是浏览器不告诉 JS 结果而已。

所以 CORS 的设计哲学是:

  1. 不破坏 Web 既有的资源加载模型(script/img/form 该怎么用还怎么用);
  2. 把 "是否允许读响应" 的决定权交给服务器(服务器通过 ACAO 头显式授权);
  3. 浏览器只做最后一道守门人------ 服务器没说允许,JS 就别想读。

这也解释了为什么 CORS 防不住 CSRF:CSRF 是 "请求被发出去了、数据被改了",只是 JS 读不到响应而已。要防 CSRF,还得靠 Token、SameSite Cookie、Referer 校验这些手段。

CORS 和 CSRF 是两个层面的问题,不要混淆。

相关推荐
布兰妮甜3 个月前
跨域全方案对比:CORS、Nginx 反向代理、JSONP、iframe、postMessage
nginx·跨域·cors·前端架构·浏览器安全
冰箱上的笑话3 个月前
从 CORS 报错到两套方案选型:一份能直接抄的跨域复盘
状态模式·安全性测试·跨域
随风一样自由4 个月前
【前端+跨域】跨域问题全面解析与解决方案指南
前端·跨域
山海云端有限公司4 个月前
随机诗词API实战:Vue项目接入完整示例与5个常见坑
vue·实战·前端开发·跨域·api调用·随机诗词api
爱看老照片5 个月前
浏览器的同源策略以及跨源问题 ( 浏览器的同域策略以及跨域问题)
浏览器·web·同源策略·跨域
身如柳絮随风扬6 个月前
深度解析跨域问题:真实场景、解决方案与进阶方案
跨域
We་ct6 个月前
深度剖析浏览器跨域问题
开发语言·前端·浏览器·跨域·cors·同源·浏览器跨域
胡志辉的博客7 个月前
本地明明好好的,怎么一上线就跨域了?把同源策略、前后端分工和 CORS 一次讲明白
前端·javascript·vue.js·reactjs·nextjs·跨域